Deploying a Rails app with MRSK

13 Jun 2023
Are you eager to elevate your security skills and safeguard your applications against cyber threats? I created a Rails Security course is designed specifically for developers like you who aim to build robust, secure Rails applications!
Buy my course: Security for Rails Developers.

This is an outdated article. Click here to read an updated version:
Deploying a Rails app with Kamal

What is MRSK?

You probably already heard about mrsk, a new tool from DHH to deploy Rails apps with docker containers. It is pretty similar to Capistrano, with the difference of using containers, so preparing the servers is less effort and you don’t really need to know much in that area to be able to deploy a Rails app.

In this tutorial, I will show you how to

  • deploy a Rails app to a VPS
  • run Caddy in front of the docker container to handle SSL
  • use a hosted database server
  • run Redis on the same droplet
  • run a worker to process background jobs

Setting up Caddy

Caddy is an open-source web server with automatic SSL certificates, so it can be used in front of Puma servers. To set up Caddy on a droplet, follow the process described in the official docs. For instance, on a Debian-based machine, you need to run the following shell commands:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

The above commands will add the package registry to the server with its key, updates the list of available packages, and install Caddy. At the end of the installation, Caddy will be automatically started as a systemd service and will listen on ports 80 and 443. Caddy is configured via the /etc/caddy/Caddyfile, so let’s open that file with our favorite text editor(vim) on the server and setup our domain:

# /etc/caddy/Caddyfile
{
  debug
  log {
    output file /var/log/caddy/caddy.log {
      roll_size 10MB
    }
  }
}

www.yourdomain.com {
  redir https://{host}{uri}
}

yourdomain.com {
  reverse_proxy tcp://0.0.0.0:8080
}

You also need to point your DNS records to the IP address of the droplet, and they will probably propagate by the time you finish your first deployment.

Setup MRSK

To set up mrsk in your app, you need to install the gem. It doesn’t have to be in the Gemfile, just needs to be available in your terminal, so you can just run gem install mrsk. Once that’s done, you need to call mrsk init --bundle inside your Rails app:

[~/git/mrsk-example] +(main) mrsk init --bundle

Created configuration file in config/deploy.yml
Created .env file
Created sample hooks in .mrsk/hooks
Adding MRSK to Gemfile and bundle...
  INFO [55b3727e] Running /usr/bin/env bundle add mrsk as gregmolnar@localhost
  INFO [55b3727e] Finished in 3.111 seconds with exit status 0 (successful).
  INFO [12808c1a] Running /usr/bin/env bundle binstubs mrsk as gregmolnar@localhost
  INFO [12808c1a] Finished in 0.120 seconds with exit status 0 (successful).
Created binstub file in bin/mrsk

The first thing I recommend to do is to gitignore the .env file, so you are not committing your secrets accidentally.
If you don’t already have access to a container registry, now is the time to sign up for one. Some hosting providers, like Digital Ocean offer one with a free allowance.
After that, let’s open the .env file and add your container registry credentials and your Rails master key.

If you generated your Rails app with 7.1+, you will already have a Dockerfile, but if you are on an older version, you will need to create one, and it will need to look like this:

# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config curl

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile


# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile


# Final stage for app image
FROM base

# Install packages needed for deployment
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y libvips postgresql-client curl && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Copy built artifacts: gems, application
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN useradd rails --home /rails --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER rails:rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000

What this Dockerfile does, in a nutshell, is it configures the base image, the working directory, and the environment variables. It installs the necessary apt packages(make sure curl is added since it was not in the default Dockefile generated by Rails at the beginning), bundles the gems, copies over the application code, precompiles the assets, creates a non-root user to own the files and run the Rails process, sets the entry point and exposes port 3000. In the default Rails Dockerfile, there is also a command to start the Rails server, but since we will also have a worker, we will run different commands in the containers and set those in the mrsk config file.

And that is the next one you need to modify. This is what you will end up with. I will break it down below:

# Name of your application. Used to uniquely configure containers.
service: my_awesome_app
# Name of the container image.

image: container_registry/my_awesome_app

# Deploy to these servers.
servers:
  web:
    hosts:
      - 111.11.111.11
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "./bin/rails server"
  job:
    hosts:
      - 111.11.111.11
    options:
      "add-host": host.docker.internal:host-gateway
    cmd: "bundle exec sidekiq -C config/sidekiq.yml -v"

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: registry.digitalocean.com
  username:
    - MRSK_REGISTRY_PASSWORD

  # Always use an access token rather than real password when possible.
  password:
    - MRSK_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
env:
  clear:
    REDIS_URL: "redis://host.docker.internal:36379/0"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD
    - SMTP_USER
    - SMTP_PASSWORD
    - DO_BUCKET_KEY
    - DO_BUCKET_SECRET
    - DO_BUCKET
    - SIDEKIQ_USERNAME
    - SIDEKIQ_PASSWORD
# Configure builder setup.
# builder:
#   args:
#     RUBY_VERSION: 3.2.0
#   secrets:
#     - GITHUB_TOKEN
#   remote:
#     arch: amd64
#     host: ssh://app@192.168.0.1
builder:
  multiarch: false
# Use accessory services (secrets come from .env).
accessories:
  redis:
    image: redis:latest
    roles:
      - web
    port: "36379:6379"
    volumes:
      - /var/lib/redis:/data
# Configure custom arguments for Traefik
traefik:
  host_port: 8080
  #publish:
  #  - 8080:8080
#   args:
#     accesslog: true
#     accesslog.format: json
# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
#   path: /healthz
#   port: 4000

In this file, you need to specify the name of your app and the image you want to use. Then you can configure servers. In this example, there is a “web” server and a “job” server. They are both running on the same host, so let’s make docker to put them on the internal network so they can access each other.

On the web server, mrsk will start a Rails server, and on the job server it will start a Sidekiq process.

Then we configure the registry access details, and then we set the environment variables we need. REDIS_URL is not a secret, so we can just enter it directly. The rest will be pulled from the .env file.
One important thing to note here is that you need to make sure the Redis port is closed on the firewall. I generally recommend to close all ports and just open the ones you have to.

The next step is to configure our builder. If you are on a Linux machine and deploying to Linux servers with the same architecture(like me), you can disable multiarch like in the above example to speed up the build process.

The next section configures the “accessories”. An accessory can be a database server, memcached, etc., or Redis in this case. As for the database, let’s use a hosted one by your provider(like Digital Ocean). For Redis, you will use the same VM.
You need to configure the image, set the role you want to put it on, forward the ports to the host, and mount a volume.

And the final part of this config file sets Traefik to run on port 8080 since Caddy is running in front of it.

One final step is to turn on a firewall, preferably at your provider, and block everything except port 80 and 443, so Treafik and Redis are not accessible from the internet.

Everything is configured now, and it is time to build the server. For that, you need to call bin/mrsk setup and wait patiently until your first build completes and deploys. Afterward, you can just run bin/mrsk deploy to deploy a new build.

Once your app is deployed and your DNS records are propagated, you can access your newly deployed application.

Here are a few handy mrsk commands:

  • If you want to start a Rails console: mrsk app exec -i "bin/rails c"
  • If you want to see your logs: mrsk app logs -f
  • If you have a stuck lock file, ssh to the host and delete the mrsk_lock folder
  • mrsk --help lists all the available commands

I hope this article helps to get your feet wet with mrsk.

Or follow me on Twitter

Related posts