This is the second part of a series of posts on deploying a Rails app to a Docker Swarm cluster on a remote VPS. In the first part, we set up the Swarm cluster on Hetzner Cloud and deployed a Rails app to it from our local machine. If you haven’t read it yet, you can find it here. In this post, we’ll automate the deployment process with GitHub Actions, so that every time you push your code to GitHub a CI/CD pipeline will build and deploy your app to the Swarm.

We are not going to cover linting and testing in this post, but if you want to add those steps to your pipeline, you can check out this PR merged into Rails to add a workflow that runs Brakeman, RuboCop, and the test suite on every push and pull request to the main branch of the repository.

Handle environment variables

Let’s review the stack file we used to deploy the app to the Swarm in the first part of this series.

version: '3.7'

services:
  web:
    image: <dockerhub_username>/swarm-demo:prod
    depends_on:
      - database
    ports:
      - 80:3000
    environment:
      - RAILS_MASTER_KEY=c51c56d33c031224c4678f44dd7eafa0
      - POSTGRES_USER=swarm_demo
      - POSTGRES_PASSWORD=production-secure-password
    deploy:
      placement:
        constraints:
          - node.labels.type == app

  database:
    image: postgres:15
    environment:
      - POSTGRES_USER=swarm_demo
      - POSTGRES_PASSWORD=production-secure-password
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    deploy:
      placement:
        constraints:
          - node.labels.type == database

volumes:
  db_data:

We have some environment variables in the stack file that we don’t want to expose in the repository but definitely need them to deploy the app. In order to handle this, we will use the Bitwarden CLI to fetch the secrets from Bitwarden and inject them into the stack file when the CI/CD pipeline runs.

First, update the stack file so that it references an environment file instead of hardcoded values:

version: '3.7'

services:
  web:
    image: <dockerhub_username>/swarm-demo:prod
    depends_on:
      - database
    ports:
      - 80:3000
    env_file:
      - .env
    deploy:
      placement:
        constraints:
          - node.labels.type == app

  database:
    image: postgres:15
    env_file:
      - .env
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    deploy:
      placement:
        constraints:
          - node.labels.type == database

volumes:
  db_data:

Second, create a free Bitwarden account if you don’t have one already and add RAILS_MASTER_KEY, POSTGRES_USER, and POSTGRES_PASSWORD as secure notes to the default collection:

Bitward note

Now, install the Bitwarden CLI by following the instructions here.

Once you have the Bitwarden CLI installed and authenticated, you can fetch the secured notes by running:

bw get notes <item-id>

Hydrate .env file with Bitwarden secrets

We will implement a rake task to fetch the secrets from Bitwarden and inject them into the .env file. This is a similar but simplified version of what Kamal does when you run kamal envify.

Create said rake task in lib/tasks/env_variables.rake:

namespace :env_variables do
  # "Creates .env by evaluating .env.erb"
  task :hydrate do
    File.write(".env", ERB.new(File.read(".env.erb")).result, perm: 0o600)
  end
end

Now, create a .env.erb file in the root of your project with the following content:

<% if (session_token=`bw unlock --passwordenv BW_PASSWORD --raw`.strip) != "" %>
RAILS_MASTER_KEY=<%= `bw get notes <item-id> --session #{session_token}` %>
POSTGRES_USER=<%= `bw get notes <item-id> --session #{session_token}` %>
POSTGRES_PASSWORD=<%= `bw get notes <item-id> --session #{session_token}` %>
<% else raise ArgumentError, "session_token token missing" end %>

With this setup, when you run rake env_variables:hydrate the .env file will be created as a result of ERB.new(File.read(".env.erb")).result executing the embedded Ruby code in the .env.erb file.

Now that we can dynamically inject the secrets into the .env file, let’s handle the CI/CD pipeline.

GitHub Actions

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. GA uses the concept of a Workflow which is defined by a YAML file in the .github/workflows directory.

We are going to configure a GitHub Actions workflow to be triggered when we push our changes to the repository, deploying the app to the VPS using Docker Swarm.

First, let’s create the .github/workflows directory and create a new file (workflow). Let’s call it deploy.yml and add the following code. We will break down the code right after.

Let’s break down the name, run-name, and on keys:

name: Deploy
run-name: Deploying to Swarm cluster
on:
  push:
    branches: [main]

The name key is the name of the workflow and the run-name key is the name for workflow runs, which will appear in the list of workflow runs in the GitHub Actions tab. The on key defines the events that trigger the workflow. In this case, the workflow will run when we push to the main branch.

The jobs key defines the jobs that will run in the workflow. In this case, we have only one job called deploy that runs on an ubuntu-latest runner.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      ...

The steps key defines the steps that will run in the job. Each step is a task that can run commands or actions. The first step checks out the codebase, the second step sets up Ruby on the runner, the third step sets up Node.js so we can use the Bitwarden CLI, which is installed in the fourth step to hydrate the .env file. The fifth and sixth steps set up Docker Buildx and login to Docker Hub, respectively so that we can build and push the Docker image in the seventh step. Finally, the last step deploys the stack to the Swarm cluster.

In order to run the workflow, we need to add the secrets to the repository. Go to the repository settings, under Security, click Secrets and Variables and then click Actions. Add the following secrets:

GitHub Secrets

Once you have set up the secrets, you can push your changes to the repository and the workflow should run automatically deploying the app to the Swarm cluster.

GitHub Actions

That’s it! You have successfully automated the deployment process of your Rails app to a Docker Swarm cluster on a remote VPS using GitHub Actions. Hopefullly, this post was helpful to you. If you have any questions or suggestions, feel free to reach out to me on Twitter.