Apr 26, 2024
25 Min read

How to deploy a Rails 7 app on Docker

In this tutorial, we will deploy a Rails 7 app on a virtual server.

Our Rails app uses Nginx reverse proxy and Postgres database. We will deploy the Rails app, reverse proxy, and database on three containers on the same virtual server using Docker Compose.

Prerequisites

  1. Developer workstation (Linux, Mac, or Windows.)
  2. Virtual server with public IP address accessible from the Internet. The server must have at least 1GB of memory, 1 vCPU, and 20GB of disk space.

You can purchase the virtual server from any cloud service provider. We will not be using cloud provider managed services like load balancers in this turorial.

If you are using a Windows workstation, use Windows PowerShell to run the terminal commands in the tutorial.

Outline

  1. Background
    1. Do we need a firewall
  2. Setting up the development environment
  3. Creating the Rails application
    1. Configure Rails to use PostgreSQL in production
    2. Configure SSL settings
    3. Update the Dockerfile
    4. Build and push the container image to Docker Hub
  4. Setting up the virtual server
    1. Configure time synchronization on virtual server
    2. Install Docker
  5. Writing the Docker compose file
    1. Run Postgres container
    2. Run the Rails app
    3. Set up Nginx reverse proxy
    4. Troublehooting container startup failure
  6. Configuring HTTPS in Nginx
    1. Add X-Forwarded headers in Nginx
    2. Redirect HTTP to HTTPS
  7. Setting up a domain name
  8. Wrapping up

Background

You can deploy a Rails app quickly and easily on a PAAS solution.

But we take a different path by deploying our Rails 7 application on a virtual server together with Nginx reverse proxy and Postgres database using Docker Compose.

This DIY setup makes us responsible for the reverse proxy, database, and virtual server OS in addition to the Rails application.

This added responsibility, however, gives us some advantages over PAAS.

  1. More control on cost:

    Most PAAS solutions have a step-wise costing approach - when you cross a certain threshold - there’s a step increase in cost. On a virtual server, the cost will always be proportionate to the server capacity.

  2. Access to the host OS:

    Since we own the OS of the virtual server, we can install native extensions, run background tasks, and even run multiple web applications on the same virtual server.

  3. Flexible routing of HTTP requests:

    We own the reverse proxy on the virtual server. Nothing to prevent us to configure custom HTTP routing rules even to build a composable web application architecture.

Do we need a firewall

We are running the Rails application on a virtual server directly connected to the Internet without a firewall.

This may not be the most secure architecture for . But plenty of people are running their web applications on virtual servers exposed to the Internet without any major security issues.

It may not suite an enterprise application with hundresds and thousands of users. But it’s perfecty fine for a hobby project or to validate your startup idea.

Setting up the development environment

On the developer workstation:

  1. Install Ruby by following the installation instructions.
  2. Install Docker by following the installation instructions

Creating the Rails application

We are going to create a bare-bones blog with Ruby on Rails.

Install Rails on the developer workstation:

$ gem install rails

Create a new Rails application:

$ rails new my-blog

Run the Rails application:

$ cd my-blog
$ bin/rails server

Go to http://127.0.0.1:3000 in your browser.

Rails default home page.

Keep the rails server running and open a new tab to run the rest of the commands.

Use scaffold to create the model, view, and controller code for Articles.

$ bin/rails g scaffold articles title:string content:text

Run the database migration.

$ bin/rails db:migrate

Configure the root URL in config/routes.rb

# config/routes.rb
Rails.application.routes.draw do
  .
  .
  .
  root "articles#index"
end

Check that the rails server is still running and go to the home page http://127.0.0.1:3000. You’ll get the Articles index page.

Configure Rails to use PostgreSQL in production

Add pg gem to the Gemfile.

group :production do
  gem "pg"
end

We do not want to install the pg gem on the developer workstation. So, configure Bundler to not install the production Gems.

$ bundle config set without 'production'
$ bundle install

Configure the production database settings in config/database.yml.

# config/database.yml
...
production:
  adapter: postgresql
  prepared_statements: false
  advisory_locks: false

  # Name of the database. Add suffix _production so you won't accidentally delete it.
  database: blog_production
  pool: 5

  # Host name of the database server configured via container environment variables.
  host: <%= ENV.fetch("POSTGRES_HOST")%>
  
  # username and password retrieved from Rails credentials. 
  # Using Ruby method `dig` since we store username and password in a nested Ruby object.
  username: <%= Rails.application.credentials.dig(:pg, :username) %>
  password: <%= Rails.application.credentials.dig(:pg, :password) %>

Next, we must add the username and password fields in the config/credentials.yml.enc file.

Decrypt and open config/credentials.yml.enc in the vi editor:

$ EDITOR="vi" rails credentials:edit

If vi editor is not available, you can use VS code:

EDITOR="code --wait" rails credentials:edit

Add username and password inside pg:

pg:
  username: myblog
  password: myblog123

We are using a simple password in the tutorial. Use a strong password in production.

Configure SSL settings

Rails, by defualt enforce SSL. But we can terminate SSL at the Nginx reverse proxy and use HTTP between the Rails app and Nginx.

So uncomment this line in config/production.rb so Rails will accept HTTP:

# config/production.rb

  config.assume_ssl = true

Update the Dockerfile

Rails now includes default Docker support and creates a Dockerfile when creating a new application.

We can create a Docker image of our blog using this Dockerfile. Before doing so, we need to do some modifications. So, open the Dockerfile at the root of our working directory (my-blog) in your text editor.

Add the environment variable POSTGRES_HOST to the list of environment variables:

ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development" \
    POSTGRES_HOST="blog-postgres-1" 

This is the environment variable we used in config/database.yml to set the hostname of the database server. Don’t worry about the value of the variable as we can override it when running the container.

Add the libpq-dev package to the list of packages needed to build the gems.

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

Since we are using Postgres database server, we added the gem pg to the Gemfile. To install the pg gem, we need this package libpq-dev

We also need to ass the libpq-dev package to the list of packages to be installed for deployment. Since we are not using Sqlite in priduction remove the libsqlite3-0 package from the same list.

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

Build and push the container image to Docker Hub

Create new Docker Hub account if you don’t already have one.

Build the container image:

$ docker buildx build --platform linux/amd64  -t <docker-hub-username>/my-blog:1.0.0 .

Replace <docker-hub-username> with your actual Docker Hub username.

Login to Docker Hub:

$ docker login

Enter your Docker Hub username and password when prompted.

Push the image:

$ docker push <docker-hub-username>/my-blog:1.0.0

Great!. Now our Rails app is ready for deployment.

Setting up the virtual server

All this time we have been working on our development workstation. Now, we will set up the virtual server for deployment.

Create a virtual server from your preferred cloud provider. Use the Ubuntu 22.04 LTS as the operating system of the virtual server.

If you don’t have a virtual server from a cloud provider yet, you can complete this tutorial by using a virtual server created on your laptop via Multipass.

You must do appropriate security hardening of virtual server OS, since the virtual server is directly accessible from the Internet. We will not include security hardening steps in this tutorial.

Configure time synchronization on virtual server

Log in to the virtual server via SSH.

Check NTP service status:

$ sudo systemctl status systemd-timesyncd

If the service is running, the status should be active in the output.

Active: active (running) since Thu 2024-04-18 03:25:18 +0530; 2h 7min ago

If the service is not running, start the service:

$ sudo systemctl enable systemd-timesyncd
$ sudo systemctl start systemd-timesyncd

Assure the time is correctly synchronized:

$ sudo timedatectl status

Install Docker

Install Docker on the virtual server by following the installation instructions.

Writing the Docker compose file

Our deployment consists of three containers.

  1. PostgreSQL database
  2. Rails app
  3. Nginx reverse proxy

Instead of running these containers individually, we use Docker Compose which is a tool for deploying multi-container apps on a virtual machine.

Docker Compose uses a YAML file (known as the compose file) to define the containers and the parameters of the deployment. The compose file must be named as compose.yml and formatted as below.

version: '3'

name: blog
services:
  container1:
    .
    .
    .
  container2:
    .
    .
    .
volumes:
  volume1:
    .
    .
    .
networks:
  .
  .
  .
secrets:
  .
  .
  .
configs:
  .
  .
  .
  1. version: The compose file version.
  2. name: Name of our multi-container application.
  3. services: Defines an array of containers to be deployed.
  4. volumes: An array 0f Docker volumes used by containers.
  5. networks: An array of Docker networks used by containers.
  6. secrets: An array of Docker secrets.
  7. configs: An array of Docker configs.

On the virtual server, create directory blog:

$ mkdir blog
$ cd blog

Create a skeleton compose.yml inside blog directory in the virtual server:

$ tee compose.yml > /dev/null <<EOT
version: '3'

name: blog
services:
  # nginx:
    # nginx reverse proxy
  # railsapp:
    # the rais app
  # postgres:
    # Postgres database
volumes:
  db:
    external: true
    name: db
  assets:
    external: true
    name: assets  
networks:
  db:
    name: db
  app:
    name: app
# secrets:
  # Docker secrets
# configs:
  # configuration files
EOT

The compose file defines three services, two Docker volumes, two Docker networks, Docker secrets and configs. Three services:

  1. nginx: Nginx reverse proxy
  2. railsapp: Rails application
  3. postgres: Postgres database

Two volumes:

  1. db: Postgres database data storage volume/
  2. assets: Rails static assets like JavaScript and CSS.

Two networks:

  1. db: Connect the postgres container and railsapp container.
  2. app: Connect the railsapp with nginx.

Docker secrets are a secure method for passing parameters to a container. We will use secrets to pass the Postgres database password and the Rails master key.

Docker configs can pass configuration files to containers. We will use it to configure Nginx.

Note the directive external: true in the Docker volumes which indicates that the volumes should be created separately.

Create the Docker volumes.

$ docker volume create db
$ docker volume create assets

When the volumes are created separate, we can safely delete a container without deleting the volume which is the desired behavior for a database volume.

Run Postgres container

Let’s update each service individually in the compose file so it’s easier to get hold of what’s going on.

Update the postgres service and the db_password secret in compose.yml:

version: '3'

name: blog
services:
  # nginx:
    # nginx reverse proxy
  # railsapp:
    # the rais app
  postgres:
    # Postgres Docker image.
    image: postgres:15.6
    # 
    # Container environment variables.
    environment:
      # Database sever username. db_user value will be provided via .env file.
      POSTGRES_USER: ${db_user}
      # Database server password configured via Docker secret.
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      # Database storage file path.
      PGDATA: /db/data
    # 
    # Mount the pre-created volume `db` to the `/db/data` path.
    volumes:
      - db:/db/data
    secrets:
      - db_password
    # 
    # Publish port `5432` to the Docker network but not to the host machine.
    expose:
      - 5432
    # 
    # Attach this container to the `db` Docker network.
    networks:
      - db
volumes:
  db:
    external: true
    name: db
  assets:
    external: true
    name: assets  
networks:
  db:
    name: db
  app:
    name: app
secrets:
  # Secrete db_password created from the content in file db_password.txt
  db_password:
    file: ./db_password.txt

The secret db_password creates a Docker secret by reading the value in db_password.txt. We wil create this file and also the .env file now.

Create the .env file:

$ echo db_user=myblog > .env

Create the db_password.txt file.

$ echo myblog123 > db_password.txt

This password must be the same password you configured in Rails credentials config/credentials.yml.enc.

Run the compose file:

$ docker compose up -d

We use the -d (detach) option so the containers wil run in the background. If not the containers will be running in interactive mode and the terminal will not return.

Check the running containers.

$ docker container ls
CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS          PORTS      NAMES
f7c317be20ac   postgres:15.6   "docker-entrypoint.s…"   12 seconds ago   Up 12 seconds   5432/tcp   blog-postgres-1

The postgres container is running. Note that docker compose has named the container as blog-postgres-1. This name is created as <compose-name>-<service_name>-n.

Run the Rails app

In compose.yml, update railsapp service and add Docker secret master_key_secret:

version: '3'

name: blog
services:
  # nginx
    # nginx reverse proxy
  railsapp:
    image: cloudqubes/my-blog:3.0.0
    environment:
      POSTGRES_HOST: ${postgres_host}
    volumes:
      - assets:/rails/public
    secrets:
      - source: master_key_secret
        target: /rails/config/master.key  
    expose:
      - 3000
    networks:
      - app
      - db
    depends_on:
      - "postgres"
  postgres:
    image: postgres:15.6
    environment:
      POSTGRES_USER: ${db_user}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      PGDATA: /db/data
    volumes:
      - db:/db/data
    secrets:
      - db_password
    expose:
      - 5432
    networks:
      - db
volumes:
  db:
    external: true
    name: db
  assets:
    external: true
    name: assets  
networks:
  db:
    name: db
  app:
    name: app
secrets:
  master_key_secret:
    file: ./master.key
  db_password:
    file: ./db_password.txt

Note these key parameters we use inside the railsapp and secrets.

  1. environment:

    Set the environment varible POSTGRES_HOT that is referred in config/database.yml in the Rails app. Docker will read the value ``${postgres_host} from .env` file.

  2. volumes:

    Mount volume assets to path /rails/public inside the container. In the next section we will see how we use this volume to make the Rails static assets available to Nginx.

  3. secrets:

    Copy Rails master.key file to /rails/config/master.key path inside the container.

  4. depends_on:

    The Rails application depends on the postgres database. The depends_on directive ensures that the railsapp container is started after the postgres container is up.

  5. master_key_secret:

    Add new Docker secret master_key_secret to pass the Rails master.key to the container.

Add the postgres_host environment variable to .env:

$ echo postgres_host=blog-postgres-1 >> .env

Create master.key file:

$ echo <master_key> > master.key

<master_key> is the value in config/master.key file inside the my-blog folder in your developer workstation.

Run the compose file:

$ docker compose up -d

Now, we have two containers up and running.

$ docker container ls
CONTAINER ID   IMAGE                      COMMAND                  CREATED         STATUS         PORTS      NAMES
7f861523c8b5   cloudqubes/my-blog:3.0.0   "/rails/bin/docker-e…"   5 seconds ago   Up 4 seconds   3000/tcp   blog-railsapp-1
d1c879a11f6b   postgres:15.6              "docker-entrypoint.s…"   6 seconds ago   Up 4 seconds   5432/tcp   blog-postgres-1

Set up Nginx reverse proxy

Create file nginx.conf inside blog directory in the virtual server:

server {
    listen       80;
    listen  [::]:80;

    location / {
      root   /usr/share/nginx/html;
      try_files $uri @rails;
    }
    
    location @rails {
      proxy_redirect off;
      proxy_pass http://blog-railsapp-1:3000;
    }
}

This file configures Nginx to listen on port 80 and serve HTTP requests from the path /usr/share/nginx/html. If a request cannot be mapped to a static file in /usr/share/nginx/html, Nginx will proxy the request to the rails app at http://blog-railsapp-1:3000.

Update compose.yml file:

version: '3'

name: blog
services:
  nginx:
    image: nginx:1.25.4-alpine
    ports:
      - "80:80"
    volumes:
      - assets:/usr/share/nginx/html
    configs:
      - source: nginx_config
        target: /etc/nginx/conf.d/default.conf
    networks:
      - app
    depends_on:
      - "railsapp" 
  railsapp:
    image: cloudqubes/my-blog:3.0.0
    environment:
      POSTGRES_HOST: ${postgres_host}
    volumes:
      - assets:/rails/public
    secrets:
      - source: master_key_secret
        target: /rails/config/master.key  
    expose:
      - 3000
    networks:
      - app
      - db
    depends_on:
      - "postgres"
  postgres:
    image: postgres:15.6
    environment:
      POSTGRES_USER: ${db_user}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      PGDATA: /db/data
    volumes:
      - db:/db/data
    secrets:
      - db_password
    expose:
      - 5432
    networks:
      - db
volumes:
  db:
    external: true
    name: db
  assets:
    external: true
    name: assets  
networks:
  db:
    name: db
  app:
    name: app
secrets:
  master_key_secret:
    file: ./master.key
  db_password:
    file: ./db_password.txt
configs:
  nginx_config:
    file: ./nginx.conf

Note these key parameters we have updated inside nginx service and configs.

  1. volumes:

    Mount the volume assets at /usr/share/nginx/html path inside the Nginx container. Previously, we mounted the same volume at /rails/public where Rails static assets reside inside our Rails app container. Mounting the assets volume to the Nginx container will make those static assets avialble to the Nginx container so Nginx can take care of serving static assets.

  2. ports:

    Publish port 80 to the host. You may note that in the other two containers we used expose which publishes the port inside the Docker network. But, since Nginx must be able to receive incoming traffic to port 80 on the host, we used ports instead of expose.

  3. configs:

    Create file /etc/nginx/conf.d/default.conf inside the Nginx container from Docker config nginx_config.

  4. nginx_config:

    Creates a Docker config from file nginx.conf file.

Run the compose file:

$ docker compose up -d

Check the containers:

$ docker container ls 
CONTAINER ID   IMAGE                      COMMAND                  CREATED          STATUS          PORTS                               NAMES
4fbfcce28b22   nginx:1.25.4-alpine        "/docker-entrypoint.…"   7 seconds ago    Up 7 seconds    0.0.0.0:80->80/tcp, :::80->80/tcp   blog-nginx-1
71484d3ae50a   cloudqubes/my-blog:3.0.0   "/rails/bin/docker-e…"   51 seconds ago   Up 50 seconds   3000/tcp                            blog-railsapp-1
a5a89e30502d   postgres:15.6              "docker-entrypoint.s…"   51 seconds ago   Up 51 seconds   5432/tcp                            blog-postgres-1

Open browser in your developer workstation and access your blog at http://<public_ip>.

<public_ip> is the public IP address of your virtual server.

Troublehooting container startup failure

If all three containers are not running, check docker compose logs:

$ docker compose logs

This will print container logs for all containers defined in the compose file.

Print logs of each service.

$ docker compose logs <service-name>

Replace <service-name> with a service name (nginx, railsapp, or postgres) in the compose file.

Configuring HTTPS in Nginx

Our blog is running on HTTP. So, let’s enable HTTPS.

To enable HTTPS, you need to get a certificate from a CA. To get the certificate you must have a registered domain. If you have a registered domain, go ahead and get a certificate now.

If not, just create a self-signed certificate so you can do the Nginx configuration now and purchase the certificate later.

In the virtual server create new directory cert inside the blog directory.

$ mkdir ~/blog/cert
$ cd ~/blog/cert

Create self-signed certificate:

$ sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./nginx.key -out ./nginx.crt

Update nginx.conf by adding new server block for HTTPS.

server {
    listen       443 ssl;
    listen  [::]:443 ssl;

    ssl_certificate /etc/nginx/nginx.crt;
    ssl_certificate_key /etc/nginx/nginx.key;
    
    location / {
      root /usr/share/nginx/html;
      try_files $uri @rails;
    }
    
    location @rails {
      proxy_redirect off;
      proxy_pass http://blog-railsapp-1:3000;
    }
}

Do not alter the existing server block as we need our app to respond to both HTTP and HTTPS.

We must copy the nginx.crt and nginx.key files to /etc/nginx path in the container. We will do that using Docker secrets.

Update secrets in compose.yml.

secrets:
  master_key_secret:
    file: ./master.key
  db_password:
    file: ./db_password.txt
  nginx_crt:
    file: ./cert/nginx.crt
  nginx_key:
    file: ./cert/nginx.key

We are creating two new secrets nginx_crt and nginx_key.

Update ports directive of nginx service in compose.yml to publish HTTPS port. Also, update the nginx_crt and nginx_key secrets inside nginx.

# compose.yaml
  nginx:
    image: nginx:1.25.4-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - assets:/usr/share/nginx/html
    configs:
      - source: nginx_config
        target: /etc/nginx/conf.d/default.conf
    networks:
      - app
    secrets:
    - source: nginx_crt
      target: /etc/nginx/nginx.crt
    - source: nginx_key
      target: /etc/nginx/nginx.key
    depends_on:
      - "railsapp" 
.
.
.

Redeploy the app.

$ docker compose down
$ docker compose up -d

From the developer workstation go to https://<public_ip> to access your Rails app on HTTPS.

If you are using a self-signed certificate, the browser will prompt a warning which you must acknowledge to continue to the website.

Click on New article. Write some dummy text for Title and Content fields an try to create a new article. It will not be successful.

Check the Rails app logs.

$ docker logs blog-railsapp-1

If you carefully inspect the log, you’ll find this part.

E, [2024-04-27T00:10:23.690597 #45] ERROR -- : [31f016a7-898c-432e-9c45-2a5a02b9b91d]   
[31f016a7-898c-432e-9c45-2a5a02b9b91d] ActionController::InvalidAuthenticityToken (HTTP Origin header (https://192.168.64.33) didn't match request.base_url (https://blog-railsapp-1:3000)): 

This is a built in security feature in Rails to prevent CSRF. We will dive deep into this matter in a separate article. For the moment, let’s resolve this problem by configuring X-Forwarded headers in Nginx.

Add X-Forwarded headers in Nginx

Update the nginx.conf in the virtual server:

server {
    listen       443 ssl;
    listen  [::]:443 ssl;
    server_name  ror-blog.com;

    ssl_certificate /etc/nginx/nginx.crt;
    ssl_certificate_key /etc/nginx/nginx.key;
    
    location / {
      root /usr/share/nginx/html;
      try_files $uri @rails;
    }
    
    location @rails {
      proxy_redirect off;
      proxy_pass http://blog-railsapp-1:3000;

      proxy_set_header  Host $host;
      proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
    }

Restart nginx service:

$ docker compose restart nginx

Open https://<public_ip> in your web browser in the developer workstation and try to create a new article again. You’ll be able to create the article and Rails will redirect to https://<public-ip>/articles/1.

Redirect HTTP to HTTPS

If users try to access our web application via HTTP, we want to redirect them to HTTPS.

Update the HTTP server block in nginx.conf in the virtual server:

# blog/nginx.com
server {
    listen       80;
    listen  [::]:80;

    # location / {
    #   root /usr/share/nginx/html;
    #   try_files $uri @rails;
    # }
    
    # location @rails {
    #   proxy_redirect off;
    #   proxy_pass http://blog-railsapp-1:3000;
    # }
    return 302 https://$host$request_uri;
}

We have removed the location directive and added a new configuration directive return. It tells Nginx to send a 302 response with the HTTPS URL whenever an HTTP request is received.

Restart nginx service.

$ docker compose restart nginx

Try to access the application via HTTP and verify that you get redirected to HTTPS.

Setting up a domain name

Buy a domain name from a domain registrar and configure DNS for the public IP address of the virtual server. Refer to the domain registrar’s documentation on how to configure DNS.

You don’t need to configure anything in Nginx or Rails for setting up a domain name.

Wrapping up

That’s it. Our Rails application is live now.

As we already mentioned, this setup is quite OK for a hobby project or validating a startup idea. But as your application scales, you need to consider more on web application security.

Also, we did not build redundancy for the Postgres database in this tutorial. Database redundancy is a critical requirement you must consider before going live in this setup.

With or without redundancy, database administration is a lot of work. You can offload that work to the cloud provider with a managed database solution. But, managed databases are also quite expensive. Consider both the effort and cost of DIY vs managed databases when choosing one.

Now that our Rails app is live, we need a mechanism to push regular updates to production. Docker makes it easy. Let’s see how in the next tutorial.