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
- Developer workstation (Linux, Mac, or Windows.)
- 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 this virtual server from any cloud service provider as we will not be using any managed services like load balancers or firewalls.
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
- Step-2: Creating the Rails application
- Step-3: Setting up the virtual server
- Step-4: Writing the Docker compose file
- Step-5: Configuring HTTPS in Nginx
- Step-6: Setting up a domain name
- Wrapping up
Step-1: Setting up the development environment
On the developer workstation:
- Install Ruby by following the installation instructions.
- 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.
.
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.
- PostgreSQL database
- Rails app
- 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:
.
.
.
version
: The compose file version.name
: Name of our multi-container application.services
: Defines an array of containers to be deployed.volumes
: An array 0f Docker volumes used by containers.networks
: An array of Docker networks used by containers.secrets
: An array of Docker secrets.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:
nginx
: Nginx reverse proxyrailsapp
: Rails applicationpostgres
: Postgres database
Two volumes:
db
: Postgres database data storage volume/assets
: Rails static assets like JavaScript and CSS.
Two networks:
db
: Connect thepostgres
container andrailsapp
container.app
: Connect therailsapp
withnginx
.
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.