Overview

The server stack here is: Ubuntu droplet → nginx with the Passenger module built in → Rails app. There is no separate Puma/systemd service to manage — Passenger runs the app inside nginx, so restarting nginx is what brings new code live.

Two things need to exist for automated deploys to work:

  1. The host — provisioned once, following the exact sequence below: deploy user → firewall → SSH hardening → RVM/Ruby → PostgreSQL → Node/Yarn → Passenger → nginx → SSL.
  2. The workflow file (.github/workflows/deploy.yml) — already in the repo. On every push to the deploy branch it SSHes into the host and repeats the same commands you'd otherwise run by hand: git pull, bundle install, db:migrate, assets:precompile, then restarts nginx.

Glossary — three keys, don't mix them up

Key Lives where Used for Public half goes to
Your admin key Your own laptop (~/.ssh/id_rsa) Logging into the deploy user by hand for provisioning/maintenance Server's ~/.ssh/authorized_keys (deploy user)
Actions → Server key GitHub secret SSH_PRIVATE_KEY Lets the GitHub Actions runner log into the server as the deploy user, unattended Server's ~/.ssh/authorized_keys (deploy user, same file — a second line)
Server → GitHub key (deploy_id_rsa) Server, in ~/.ssh/deploy_id_rsa Lets the server itself pull from GitHub — for private repos or private git-sourced gems in the Gemfile GitHub repo → Settings → Deploy keys

If the repo is cloned over plain HTTPS (as in step 2.1 below) and nothing in the Gemfile needs SSH, the third key is optional. It's included because it's what the workflow script assumes, and it costs nothing to set up once.

1. One-time host setup DigitalOcean · Ubuntu

Skip this whole section if the droplet already hosts another app deployed the same way — reuse the same deploy user, RVM, PostgreSQL, and Passenger install for every new project.

1.1 Create the deploy user

adduser deploy
usermod -aG sudo deploy

1.2 Enable the firewall

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable

1.3 Add your SSH key to the deploy user

On your local machine (skip if you already have a key pair):

ssh-keygen
cat ~/.ssh/id_rsa.pub

On the server, switch into the new user and register that public key:

su deploy
mkdir -p ~/.ssh
echo "public_key_string" >> ~/.ssh/authorized_keys
chmod -R go= ~/.ssh
chown -R deploy:deploy ~/.ssh

Replace public_key_string with the actual output of cat ~/.ssh/id_rsa.pub from your machine — don't paste it literally.

1.4 Harden SSH — disable password login

sudo nano /etc/ssh/sshd_config

Change:

PasswordAuthentication yes
# to
PasswordAuthentication no
sudo systemctl restart ssh

Confirm step 1.3 actually works — open a second terminal and successfully key-login as deploybefore restarting ssh with password auth disabled. Otherwise you can lock yourself out with no way back in except the provider's console.

1.5 Passwordless sudo for the deploy user

sudo visudo

Add at the bottom of the file:

deploy ALL=(ALL) NOPASSWD: ALL

This is the broad form used across these projects — it's what lets the CI script's sudo service nginx restart run unattended without hanging on a password prompt. If you'd rather not grant blanket passwordless sudo, scope it to just /usr/sbin/service nginx restart instead (see Security notes).

1.6 Install RVM and Ruby

Log out twice (you land in root, then again to get back to your own machine) and back in as deploy before running these, so RVM installs under the right user:

ssh deploy@<host>

# only if gpg2 isn't already installed
sudo apt-get install gnupg2 -y

gpg2 --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB

\curl -sSL https://get.rvm.io | bash -s stable
source ~/.rvm/scripts/rvm
rvm install <ruby version> --default

If the key fingerprint above is rejected, grab the current one from rvm.io — RVM occasionally rotates signing keys.

1.7 Install PostgreSQL

sudo apt-get install postgresql postgresql-contrib libpq-dev

# lets nginx/Passenger traverse into the deploy user's home directory
sudo chmod og+rX /home /home/deploy/

sudo -u postgres createuser -s deploy -P

The password you set here for the deploy Postgres role is the one that goes into each project's .env as POSTGRES_PASSWORD later.

1.8 Install Yarn and Node.js

curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update && sudo apt install --no-install-recommends yarn

wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
source ~/.profile
nvm install <version> --default

1.9 Install Passenger

sudo apt install libcurl4-openssl-dev
sudo apt-get install -y dirmngr gnupg
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 561F9B9CAC40B2F7
sudo apt-get install -y apt-transport-https ca-certificates

# this exact apt source works on Ubuntu 22 (jammy); for other versions check
# https://www.phusionpassenger.com/
sudo sh -c 'echo deb https://oss-binaries.phusionpassenger.com/apt/passenger jammy main > /etc/apt/sources.list.d/passenger.list'
sudo apt-get update
sudo apt-get install -y libnginx-mod-http-passenger

rvmsudo passenger-install-nginx-module

1.10 Install nginx and add SSL via certbot

sudo apt install nginx

Per-domain nginx configuration is covered in step 2.4 once a project actually exists to point at. Once a site block is live on port 80, issue a certificate:

sudo snap install --classic certbot
sudo certbot --nginx

Certbot rewrites the site's server block to add the 443 listener and SSL directives, and adds an HTTP→HTTPS redirect block. Set up renewal:

sudo crontab -e
30 2 * * 1 certbot renew

1.11 Optional: image processing

sudo apt-get -y install imagemagick

Needed if the app uses Active Storage variants for image resizing.

2. Per-project setup do this for every new app

2.1 Clone the repo

cd ~
sudo mkdir www
cd www
sudo git clone https://<repository>.git
sudo chown deploy -R <repository>
cd <repository>

2.2 Install gems

gem install bundler
bundle install

2.3 Rails credentials (only if the master key needs resetting)

If you don't already have the project's real config/master.key, generate a fresh encrypted credentials file rather than trying to guess the old one:

EDITOR="nano" bin/rails credentials:edit
rm config/credentials.yml.enc
EDITOR="nano" bin/rails credentials:edit

Skip this entirely if you already have the correct master.key for this app — this only applies to bootstrapping a project that doesn't have one on this server yet.

2.4 Configure .env and the database

chmod 700 config db
chmod 600 config/database.yml

If the repo has a .env-template file (ls .env-template shouldn't error):

sudo cp .env-template .env

Otherwise create it directly with at least:

sudo nano .env
POSTGRES_USER='deploy'
POSTGRES_PASSWORD='<password from step 1.7>'
POSTGRES_DB='<projectname>'

Then build assets and set up the database:

rails assets:precompile db:{create,migrate,seed} RAILS_ENV=production

2.5 nginx server block for this project

sudo nano /etc/nginx/sites-available/<domain>
server {
    listen 80;
    server_name yourserver.com;
    root /home/deploy/www/<repository>/public;
    rails_env production;
    passenger_enabled on;
    passenger_ruby /home/deploy/.rvm/rubies/ruby-<ruby-version>/bin/ruby;
}

Repeat for www.<domain> too if that's in use, then enable the site:

sudo ln -s /etc/nginx/sites-available/<domain> /etc/nginx/sites-enabled/
sudo service nginx start

Now go back to step 1.10 to issue the SSL certificate for this specific domain.

3. CI/CD with GitHub Actions

Everything in section 2 above — pulling code, installing gems, migrating, precompiling assets, restarting the app — is exactly what the workflow automates on every push. Nothing new runs on the server; it's the same commands, just triggered by a push instead of by you typing them in over SSH.

3.1 Add the deploy key for git access (optional but assumed by the script)

ssh-keygen -t ed25519 -f ~/.ssh/deploy_id_rsa -N ""
chmod 600 ~/.ssh/deploy_id_rsa
cat ~/.ssh/deploy_id_rsa.pub

Paste that public key into the repo: Settings → Deploy keys → Add deploy key. Leave "Allow write access" unchecked unless the server needs to push.

3.2 Generate the Actions → Server key

This is a separate key pair from your own admin key in step 1.3 — generate it on the server as the deploy user:

ssh-keygen -t ed25519 -f ~/.ssh/actions_deploy_key -N ""
cat ~/.ssh/actions_deploy_key.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/actions_deploy_key

Copy the full private key output — including the BEGIN/END lines — you'll paste it into a GitHub secret next. Never commit it anywhere.

3.3 Add the repository secrets

Settings → Secrets and variables → Actions → New repository secret:

Secret nameValue
HOSTServer IP address or domain
USERNAMEdeploy
SSH_PRIVATE_KEYFull private key from step 3.2

3.4 The workflow file, annotated

on:
  push:
    branches:
      - production          # ← only this branch triggers a deploy

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.HOST }}          # ← step 3.3
          username: ${{ secrets.USERNAME }}  # ← step 3.3, "deploy"
          key: ${{ secrets.SSH_PRIVATE_KEY }} # ← step 3.2 private half
          port: 22
          script: |
            set -e                             # ← abort on first failing command
            cd /home/deploy/www/<repository>  # ← step 2.1 path

            source ~/.rvm/scripts/rvm
            rvm use <ruby version>             # ← step 1.6, must match .ruby-version

            chmod 600 ~/.ssh/deploy_id_rsa || true
            ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true

            git checkout production
            git pull --ff-only
            git status

            bundle config set without 'development test'
            bundle config set path 'vendor/bundle'

            GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_id_rsa -o IdentitiesOnly=yes' \
            bundle install                     # ← step 3.1 key used here

            bundle exec rake db:migrate RAILS_ENV=production
            bundle exec rake assets:precompile RAILS_ENV=production
            sudo service nginx restart         # ← step 1.5 sudo rule; Passenger
                                                #   picks up the new code on restart

4. Running & verifying a deploy

  1. Push a small, safe commit to the deploy branch (e.g. a comment change).
  2. Open the repo's Actions tab and watch the run — appleboy/ssh-action streams the remote script's output live.
  3. If it goes green, hit the site and confirm the change is live.
  4. If it fails, the log tells you exactly which shell command failed — that's the whole point of the set -e line at the top of the script.

5. Troubleshooting

6. Security notes