Rails Push-to-Deploy

Seto Elkahfi
5 min readMar 11, 2023

--

Push to deploy or slide to unlock my heart.

- overheard in the metro.

Developing an application locally and deploying said application to a production server are two different things. A lot of things can go wrong when transitioning from a local environment to a production environment. An adage it works on my machine even drove the industry to another way of thinking. I look at you, containerization.

Heroku: Git as a deployment tool

My https://musik88.com project was initially deployed in Heroku. From the 2016–2020 period, if I’m not mistaken, I had the privilege of simply using heroku push (is that the command now?) to deploy my app to production.

When it hit peak traffic that my lowest tier pricing wasn’t enough anymore, a friend of mine suggested I move it to a commodity cloud. Hence, Hetzner. I migrated it circa 2020.

New home, new tooling

It was not a walk in the park. I had to do manually what I used to pay Heroku for premium. But the good part is, they’re all not as complicated as I imagined. After spinning up the server, prepare the Debian box with all the dependencies, and the website is up and running in its new home.

One missing point from Heroku though, is push-to-deploy. So, I embark on a new tooling journey bash script.

Push-to-deploy

It uses a git feature called a hook. In a nutshell, it listens to a push event to a particular branch, then does a set of bash commands. The following commands are inside the VM:

Create a bare git repo

$ mkdir musik88-production && cd musik88-production
$ git init --bare

Edit post-receive hooks

$ vim hooks/post-receive

The script itself:

#!/bin/bash

# The bare git directory
GIT_DIR=/home/deploy/musik88-production
# The app directory
WORK_TREE=/home/deploy/apps/musik88-production

# Put environment variabels here
. ~/.profile

while read oldrev newrev ref
do
if [[ $ref =~ .*/main$ ]];
then
echo "🚀 Main ref received. Deploying main branch to production..."
mkdir -p $WORK_TREE
git --work-tree=$WORK_TREE --git-dir=$GIT_DIR checkout -f main
mkdir -p $WORK_TREE/shared/pids $WORK_TREE/shared/sockets $WORK_TREE/shared/log

# start deploy tasks
cd $WORK_TREE

bundle install
rake db:migrate

echo "🚀 Killing existing process.."
kill $(lsof -t -i:3002)
echo "🚀 Starting puma..."
bundle exec puma -C config/puma.rb -e production > /dev/null 2>&1 &
echo "🚀 Puma is running"

echo "✅ Git hooks deploy complete"
else
echo "Ref $ref successfully received. Doing nothing: only the main branch may be deployed on this server."
fi
done

Make sure to put all environment variables that are needed for the Rails app inside the ~/.profile file.

Make post-receive Executable

Before pushing your branch to the production remote, or whatever you want to call it later, we need to make the post-receive script executable. Otherwise, one will get this error:

git --no-optional-locks -c color.branch=false -c color.diff=false -c color.status=false -c diff.mnemonicprefix=false -c core.quotepath=false -c credential.helper=sourcetree push -v --tags production refs/heads/feature/ssr:refs/heads/feature/ssr 
Pushing to a.smbpndk.com:setoelkahfi.com
hint: The 'hooks/post-receive' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`.
To a.smbpndk.com:setoelkahfi.com
* [new branch] feature/ssr -> feature/ssr
updating local tracking ref 'refs/remotes/production/feature/ssr'
Completed successfully

Open the git bare repository and run this command:

chmod ug+x hooks/post-receive

Now, we can push our production branch to our production remote.

Set remote repository

Now on my local machine, I set a new remote URL pointing to that bare repository:

$ git remote add production deploy@<IP-SERVER-OR-DOMAIN>:musik88-production

Push to deploy

Now deploying to production is as simple as git push production, from the main branch:

╰─➤  git push production
Enumerating objects: 286, done.
Counting objects: 100% (286/286), done.
Delta compression using up to 10 threads
Compressing objects: 100% (215/215), done.
Writing objects: 100% (286/286), 51.97 KiB | 4.72 MiB/s, done.
Total 286 (delta 118), reused 122 (delta 34), pack-reused 0
remote: Resolving deltas: 100% (118/118), done.
remote: 🚀 Main ref received. Deploying main branch to production...
remote: Already on 'main'
remote: Fetching gem metadata from https://rubygems.org/..........
remote: Resolving dependencies....
remote: Using rake 13.0.6
remote: Using concurrent-ruby 1.1.9
remote: Using minitest 5.15.0
remote: Using builder 3.2.4
remote: Using erubi 1.10.0
remote: Using racc 1.6.0
remote: Using crass 1.0.6
remote: Using rack 2.2.3
remote: Using nio4r 2.5.8
remote: Using websocket-extensions 0.1.5
remote: Using marcel 1.0.2
remote: Using mini_mime 1.1.2
remote: Using digest 3.1.0
remote: Using io-wait 0.2.1
remote: Using timeout 0.2.0
remote: Using strscan 3.0.1
remote: Using public_suffix 5.0.1
remote: Using bcrypt 3.1.16
remote: Using msgpack 1.4.4
remote: Using bundler 2.3.6
remote: Using database_cleaner-core 2.0.1
remote: Using orm_adapter 0.5.0
remote: Using method_source 1.0.0
remote: Using thor 1.2.1
remote: Using zeitwerk 2.5.4
remote: Using jwt 2.7.0
remote: Using diff-lcs 1.5.0
remote: Using dotenv 2.8.1
remote: Using pg 1.3.0
remote: Using rspec-support 3.9.4
remote: Using i18n 1.9.1
remote: Using tzinfo 2.0.4
remote: Using dry-container 0.11.0
remote: Using dry-core 0.8.1
remote: Using nokogiri 1.13.1 (x86_64-linux)
remote: Using rack-test 1.1.0
remote: Using warden 1.2.9
remote: Using rack-cors 1.1.1
remote: Using puma 5.6.1
remote: Using websocket-driver 0.7.5
remote: Using io-console 0.5.11
remote: Using net-protocol 0.1.2
remote: Using addressable 2.8.1
remote: Using bootsnap 1.10.2
remote: Using activesupport 7.0.1
remote: Using loofah 2.13.0
remote: Using dry-auto_inject 0.9.0
remote: Using dry-configurable 0.15.0
remote: Using faker 2.19.0
remote: Using rspec-core 3.9.3
remote: Using rspec-expectations 3.9.4
remote: Using rspec-mocks 3.9.1
remote: Using mail 2.7.1
remote: Using rails-dom-testing 2.0.3
remote: Using globalid 1.0.0
remote: Using activemodel 7.0.1
remote: Using net-imap 0.2.3
remote: Using net-pop 0.1.1
remote: Using rails-html-sanitizer 1.4.2
remote: Using reline 0.3.1
remote: Using warden-jwt_auth 0.8.0
remote: Using factory_bot 4.11.1
remote: Using jsonapi-serializer 2.2.0
remote: Using launchy 2.5.2
remote: Using shoulda-matchers 3.1.3
remote: Using activejob 7.0.1
remote: Using activerecord 7.0.1
remote: Using net-smtp 0.3.1
remote: Using actionview 7.0.1
remote: Using irb 1.4.1
remote: Using letter_opener 1.8.1
remote: Using database_cleaner-active_record 2.0.1
remote: Using actionpack 7.0.1
remote: Using debug 1.4.0
remote: Using actioncable 7.0.1
remote: Using actionmailer 7.0.1
remote: Using database_cleaner 2.0.1
remote: Using railties 7.0.1
remote: Using activestorage 7.0.1
remote: Using responders 3.0.1
remote: Using factory_bot_rails 4.11.1
remote: Using rspec-rails 3.9.1
remote: Using dotenv-rails 2.8.1
remote: Using actionmailbox 7.0.1
remote: Using devise 4.8.1
remote: Using actiontext 7.0.1
remote: Using devise-jwt 0.10.0
remote: Using rails 7.0.1
remote: Bundle complete! 17 Gemfile dependencies, 88 gems now installed.
remote: Use `bundle info [gemname]` to see where a bundled gem is installed.
remote: 🚀 Killing existing process..
remote: 🚀 Starting puma...
remote: 🚀 Puma is running
remote: ✅ Git hooks deploy complete
To <IP-SERVER>:musik88-production
* [new branch] main -> main

I hope it helps. Happy hacking!

UPDATE 11/08/2023, make post-receive script executable

--

--

Seto Elkahfi

Software developer. Rust, Ruby on Rails, AWS, iOS, tvOS,