Content outline

Last updated on May 30, 2024
24 Min read

How to deploy a Rails 7 app on Docker

In this tutorial, we will deploy a Rails 7 app, along with a Postgres database and an Nginx reverse proxy on a single virtual server with 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 as 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 of steps

Step-1: 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

Step-2: 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 Postgres server settings in Rails

Add pg gem to the Gemfile for interfacing with the Postgres database.

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 as the editotr:

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.

Before building a Docker image of our Rails app, we need to do some modifications in this Docker file. 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 used in config/database.yml to configure 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 add 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 form terminal:

$ 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.

Step-3: 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.

Since this virtual server is exposed to the Internet, you must do appropriate security hardeining which we will not cover 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.

Step-4: 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

Create two Docker volumes:

$ docker volume create db
$ docker volume create assets

The db volume stores the Postgres database. The assets volume stores the static assets (JS, CSS, images) used in the Rails app.

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
    # 
    # Expose 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

Use the -d (detach) option so the containers wil run in the background. Otherwise the containers will run 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:
    # This is the image we created and pushed to Docker Hub. 
    # Replace cloudqubes with your Docker Hub username.
    image: cloudqubes/my-blog:3.0.0
    # Configure the environment varaible used in `config/database.yml`. 
    # The value postgres_host will be read from .env file the current directory.
    # We will create the .env file later.
    environment:
      POSTGRES_HOST: ${postgres_host}
    # Mount the Docker volume assets we created earlier.
    volumes:
      - assets:/rails/public
    # Configure Docker secret master_key_secret in the container.
    # This secret configures the Rails master.ey.
    secrets:
      - source: master_key_secret
        target: /rails/config/master.key  
    # Expose port 3000 to the Docker network so the Rails app becomes accessible to the Nginx reverse proxy.
    # Note that this will not publish the port 3000 to the host server.
    expose:
      - 3000
    # Connect the Rails app to the app and db networks.
    networks:
      - app
      - db
    # The Rails app depends on the Postgres database. So, Docker Compose will
    # initialize the Rails app container only after the database container is running.
    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:
  # Create new Docker secret with the value in the file ./master.key.
  # We will create the master.key file later.
  master_key_secret:
    file: ./master.key
  db_password:
    file: ./db_password.txt

Crete the .env file and set the postgres_host environment variable:

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

Create the Rails master.key:

$ 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

Run Nginx reverse proxy container

Create the file nginx.conf inside the blog directory on 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
    # Publish port 80 so, port 80 becomes accessible for external clients.
    ports:
      - "80:80"
    # Mount the volume `assets` at `/usr/share/nginx/html` path inside the Nginx container.
    volumes:
      - assets:/usr/share/nginx/html
    configs:
      # Mount the Nginx configuration file from Docker config nginx_config.
      - 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:
  # Create Docker config from the ./nginx.conf file.
  nginx_config:
    file: ./nginx.conf

Note that we have mounted the assets volume on both the Rails app container and the Nginx container. It enables our Nginx reverse proxy to serve Rails static assets. When an HTTP request arrives, Nginx first tries to serve the request from the static assets. If Nginx cannot find a static file corresponding with the URL of the incoming HTTP request, Nginx proxies the request to the Rails app.

We have used ports directive in the Nginx container to publish port 80 to the host so that the virtual server will start listening to port 80 and channel the incoming traffic to the Nginx container.

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

On the developer workstation, open a browser tab and access your blog at http://<public_ip>.

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

Troublehooting container startup failure

If any of the containers are not running, check docker compose logs:

$ docker compose logs

This will print the logs for all containers defined in the compose file. By analyzing this log, you’ll be able to identify the reasons for container startup failures.

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.

Step-5: 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 a new server block for HTTPS.

# nginx.conf
...

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.

# 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 (443). 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 and 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:

# nginx.conf

...
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:

# nginx.conf

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.

Step-6: 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, on a single virtual server.

We are using a DIY database for this Rails deployment. Operating a DIY database demands a lot of effort and skill. A managed database solution from your cloud provider can relieve you of this burden.

Also, since our app is running on a single virtual server, we do not have a proper redundancy or a fail-over mechanism.

But this setup is quite OK for a hobby project or validating a startup idea. In an upcoming article, we will discuss more about different web app deployment architectures so you can make an informed decision on the best method for deploying your Rails app.