Rails 7: production deploy from scratch (Ubuntu 22.04 edition)
Tags: ruby, rails, ubuntu, postgres
Published: 2023-02-02 22:59:25

Here's how i setup my rails 7 deployments, starting from a fresh ubuntu 22.04 install and root user ssh access.
Part of the stack will be rbenv, nginx, passenger, postgres and capistrano, with options to set up yarn and node as well.

Replace `vim` with `nano` if you prefer to use the worse text editor.

## Prereq

* DNS is set up
* you have a SSH key generated (if not, simply run ssh-keygen)

## User setup

### Connect as root [local]

```bash
$ ssh root@<your-url>
```

### Add non-root user [remote]

```bash
$ adduser devops
  # set password 'devops_user_password'
$ adduser devops sudo
$ exit
```

### Connect as devops (for all steps below) [local]

```bash
# Run the first command below only once
$ ssh-copy-id devops@<your-url> 
$ ssh devops@<your-url>
```

### Disallow root login via SSH [remote]

```bash
$ sudo vim /etc/ssh/sshd_config
  # Set line 'PermitRootLogin yes' to 'PermitRootLogin no'
$ sudo service sshd restart
```

### Set hostname [remote]

```bash
$ sudo hostnamectl set-hostname <your-url>
$ sudo hostnamectl # check if it is set
```

## Install system dependencies [remote]

```bash
$ sudo apt update
$ sudo apt upgrade
$ sudo apt install git build-essential libssl-dev zlib1g-dev libyaml-dev
```

## Install rbenv, ruby, bundler [remote]

```bash
$ git clone https://github.com/rbenv/rbenv.git ~/.rbenv
$ echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
$ exec $SHELL
$ git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build
$ git clone https://github.com/rbenv/rbenv-vars.git "$(rbenv root)"/plugins/rbenv-vars
$ rbenv install 3.2.0
$ rbenv global 3.2.0
  # Test complete install with rbenv-doctor
$ curl -fsSL https://github.com/rbenv/rbenv-installer/raw/main/bin/rbenv-doctor | bash
$ ruby -v # Shows ruby version number if correctly installed
$ gem install bundler # You might be prompted to update some gems, see command below
$ gem update --system 3.4.6 # Check if this is recommended after bundler installation
$ bundle -v # Shows bundler version number if correctly installed
```

## Install yarn, nvm, node (skip if not required) [remote]

If you generated your rails project with default javascript configuration (i.e. importmaps), then you might not need yarn, nvm and node at all.

```bash
$ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/yarn-archive-keyring.gpg
$ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
$ sudo apt install yarn
$ yarn -v # Shows yarn version number if correctly installed
```

```bash
$ curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash
$ exec $SHELL
$ nvm install 16
$ node -v # Shows node version number if correctly installed
$ # npm install -g esbuild # Uncomment if you package javascript with esbuild
$ # esbuild --version
$ # npm install -g typescript # If needed
$ # npm install -g sass # If needed
```

## Install nginx, passenger [remote]

```bash
$ curl https://oss-binaries.phusionpassenger.com/auto-software-signing-gpg-key.txt | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/phusion-keyring.gpg >/dev/null
$ sudo sh -c 'echo deb https://oss-binaries.phusionpassenger.com/apt/passenger jammy main > /etc/apt/sources.list.d/passenger.list'
$ sudo apt update
$ sudo apt install nginx-extras libnginx-mod-http-passenger
$ sudo vim /etc/nginx/conf.d/mod-http-passenger.conf
  # Change the path to rbenv ruby
  passenger_ruby /home/devops/.rbenv/shims/ruby;
$ sudo service nginx restart
$ sudo service nginx status
$ sudo /usr/bin/passenger-config validate-install # Looks ok?
$ sudo /usr/sbin/passenger-memory-stats # Looks ok?
$ sudo rm /etc/nginx/sites-enabled/default
$ sudo vim /etc/nginx/sites-available/<your-url>
  # Example conf
server {
  server_name <your-url>;
  # server_name _;
  root /home/devops/<your-url>/current/public;
  #client_max_body_size 25m; # Set max upload size
  passenger_enabled on;
  passenger_app_env production;
  # Uncomment if you use ActionCable and/or Turbo Streams
  # location /cable {
  #   passenger_app_group_name APPNAME_ws;
  #   passenger_force_max_concurrent_requests_per_process 0;
  # }
  location ~ ^/assets {
    expires max;
    gzip_static on;
  }
  listen 80;
  listen [::]:80;
}
$ sudo ln -s /etc/nginx/sites-available/<your-url> /etc/nginx/sites-enabled/<your-url>
$ sudo service nginx restart
  # Check error log if required (like after deploy)
$ sudo vim /var/log/nginx/error.log
```

### Install SSL cert

```bash
$ sudo apt install snapd
$ sudo snap install core; sudo snap refresh core
$ sudo snap install --classic certbot
$ sudo certbot --nginx
  # Follow instructions
```

### Enable HTTP2 on nginx

```bash
$ sudo vim /etc/nginx/sites-available/<your-url>
  # Add http2 to listen command, see below
  # listen [::]:443 ssl http2 ipv6only=on;
  # listen 443 ssl http2;
$ sudo service nginx restart
```

## Install postgresql [remote]

```bash
$ sudo apt install postgresql postgresql-contrib libpq-dev
$ sudo service postgresql@14-main status
$ sudo su - postgres
  # execute the following as postgres user
$ createuser --pwprompt postgres_production
  # Enter password
$ createdb -O postgres_production <your-app-name>_production
  # check db existence with 'psql' then '\l', quit with '\q'
$ exit
```

## Set env vars [remote]

```bash
$ mkdir ~/<your-url>
$ cd ~/<your-url>
$ vim .rbenv-vars
  # Required: 
  RAILS_MASTER_KEY=<master.key content>
  SECRET_KEY_BASE=<some random sequence> # use local bundle exec rake secret
  RAILS_ENV=production
```

## capistrano setup (once per project) [local]

Add capistrano gems to `development` group in your `Gemfile`

```text
group :development do
  ...
  # Deployment
  gem 'capistrano', "~> 3.17", require: false
  gem 'capistrano-rails', "~> 1.6", ">= 1.6.2", require: false
  gem 'capistrano-passenger', "~> 0.2", ">= 0.2.1", require: false
  gem 'capistrano-rbenv', "~> 2.2", require: false
  gem 'ed25519', '>= 1.2', '< 2.0', require: false
  gem 'bcrypt_pbkdf', '>= 1.0', '< 2.0', require: false
  ...
end
```

```bash
$ bundle install
$ bundle exec cap install
```

in the file `Capfile` require the following modules

```ruby
require "capistrano/rbenv"
require "capistrano/rails"
require "capistrano/passenger"
```

in the file `config/deploy.rb` set the following configs
add your public SSH key to github

```ruby
set :rbenv_ruby, '3.2.0'
set :application, "<your-url>"
set :repo_url, "git@github.com:<your_github_handle>/<your_github_repo>.git"
set :deploy_to, "~/#{fetch :application}"
# Issue with propshaft as asset pipwlinw
# See: https://github.com/capistrano/rails/issues/257
# Workaround
set :assets_manifests, -> {
  [release_path.join("public", fetch(:assets_prefix), '.manifest.json')]
}
```

in the file `config/deploy.rb` set the following configs

```ruby
server '<your-url>', user: 'devops', roles: %w{app db web}
set :branch, "main"
```

## Deploy [local]

```bash
  # if ssh-key is not recognized run the following two commands
$ eval `ssh-agent`
$ ssh-add ~/.ssh/id_rsa
  # Deployment
$ cap production deploy
```

## Security (optional but recommended) [remote]

### Set up firewall

```bash
$ sudo ufw default allow outgoing
$ sudo ufw default deny incoming
$ sudo ufw allow ssh
$ sudo ufw allow 80/tcp comment 'accept http'
$ sudo ufw allow 443/tcp comment 'accept https'
$ sudo ufw enable
$ sudo ufw status
```

### Set up fail2ban

```bash
$ sudo sudo apt update; sudo apt upgrade
$ sudo apt install fail2ban
$ # sudo apt install sendmail # do this only if you want to config sending fail2ban reports
$ sudo systemctl enable fail2ban
$ sudo systemctl start fail2ban
  # .local files overwrite values from .conf files
$ sudo cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
$ sudo vim /etc/fail2ban/fail2ban.local
$ sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
  # This is the main config file, defaults for sshd are ok
$ sudo vim /etc/fail2ban/jail.local
$ sudo fail2ban-client status
  # detail of sshd jail
$ sudo fail2ban-client status sshd
```

How did your deployment go? Anything you like to do differently? Leave a comment below!