Here’s one way of a cloud-independent deployment of Rails, Sidekiq, PostgreSQL, and Redis on single virtual server with Kamal.
TIP: Checkout Kamal Handbook, my new book on Kamal.
What’s Kamal
Kamal is a new deployment tool from 37signals (authors of Basecamp and Hey). It’s a philosophical successor to Capistrano but made for the container era.
Kamal keeps things simple by employing a push agentless model without any declarative orchestration. The model has its downsides, but on the plus side it uses the plain Docker you likely know. Conceptually, Kamal deployment is a combination of Bash (deployment hooks), Ruby (Kamal’s source), YAML (configuration), Traefik (load balancer), and Docker (process manager).
It will automatically install Docker, (cross) build your containers, push them to a container repository, and deploy the new versions by fetching it on the servers. Zero-downtime deployment is done by booting the new version before switching over with Traefik.
One thing to note about Kamal is that while it can run your containers, it doesn’t concern itself with further server management.
Single server
A lot of great projects can run on a single server. I’ll almost always consider starting this way as it simplified a lot of things. Kamal was primarily designed as a multi-node deployment tool, and as such it misses good documentation on a single-server deployment. Nevertheless it’s still possible.
The three core ideas to achieve that are:
-
Making Traefik issue the Let’s Encrypt certificates which will require a DNS challenge and a local directory.
-
Introducing a private bridge Docker network for container communication without the need of using IP addresses or exposing ports.
-
Having a mounted volume or a simple directory for a local disk service to avoid a 3rd-party object storage.
In this post we’ll look at how to do that for a very typical Ruby on Rails application with a PostgreSQL relational database, Sidekiq, and a Redis key-value store. Please make sure you are already a bit familiar with Kamal.
Pre-requisities
To write a desired configuration, we’ll need to prepare a couple of things beforehand.
-
Buy a domain name for the SSL/TLS setup.
-
Set up emails on the domain name for the Let’s Encrypt email contact.
-
Set up a Docker registry.
-
Creat an SSH key.
You can create one like this:
$ ssh-keygen -t ed25519 -C "support@imagebinary.com"
-
Buy a virtual private server and set it up with the SSH key from previous step.
-
Update DNS A and CNAME entries so that the domain leads to the public IP of the VPS.
-
Create an
.env
file where you’ll put your secrets. -
Prepare
RAILS_MASTER_KEY
for Rails Encrypted Credentials.Once you are done, install Kamal and run
init
:$ gem install kamal $ kamal init
This should install some files including config/deploy.yml
.
Configuration
Your config/deploy.yml
configuration starts with your application name and image:
# config/deploy.yml
# Name of your application. Used to uniquely configure containers.
service: myapp
image: username/imagename
Kamal’s assumption is that one application is served by one single image. The service name will be prepended to your application and accessory container names.
Registry
Once you specify your image, you’ll need to tell Kamal where to push it and when to pull it from. Provide your Docker registry server, username, and password:
# config/deploy.yml
...
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: strzibnyj
# Always use an access token rather than real password when possible.
password:
- KAMAL_REGISTRY_PASSWORD
If you use Dockerhub you won’t need the server
directive as it’s default. Then put the actual value of KAMAL_REGISTRY_PASSWORD
inside the .env
file.
Traefik
Traefik is the bread and butter of our single server setup. It will handle TLS termination, https:
redirect, and zero downtime deployments. As a special component it has its own entry within config/deploy
. Traefik takes options
which let us mount the acme.json
file required for Let’s Encrypt and specify the Docker private network, and args
which lets us specify the entrypoints as well as the details for challenge:
# config/deploy.yml
...
traefik:
options:
publish:
- "443:443"
volume:
- "/letsencrypt/acme.json:/letsencrypt/acme.json"
network: "private"
args:
entryPoints.web.address: ":80"
entryPoints.websecure.address: ":443"
certificatesResolvers.letsencrypt.acme.email: "support@imagebinary.com"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
servers:
...
That’s however not the entire Traefik story as we’ll need the routers host rules. We’ll add them next when specifying our servers
configuration.
Servers
The main configuration of Kamal happens inside servers
directive. In our case we’ll have only one host hosting both the Rails application server as the web
container as well as Sidekiq as the job
container. We’ll need to specify our public IP address among the hosts
array of servers, provide domain name labels for the web container, and specify custom cmd
to run the background jobs:
# config/deploy.yml
...
servers:
web:
hosts:
- 165.227.160.215
labels:
traefik.http.routers.imagebinary.rule: Host(`imagebinary.com`)
traefik.http.routers.imagebinary_secure.entrypoints: websecure
traefik.http.routers.imagebinary_secure.rule: Host(`imagebinary.com`)
traefik.http.routers.imagebinary_secure.tls: true
traefik.http.routers.imagebinary_secure.tls.certresolver: letsencrypt
options:
network: "private"
job:
hosts:
- 165.227.160.215
# Or something like bin/job
cmd: bundle exec sidekiq
# Limit workers resources
#
# options:
# cap-add: true
# cpu-count: 2
options:
network: "private"
We’ll again use options
to specify our new private network. If we want we can limit server resources with cap-add
. This can be helpful if we don’t want our workers to take down the entire application.
PostgreSQL
PostgreSQL is our primary accessory which is the Kamal terminology for auxiliary services.
A lot of people will opt-in for a managed service, but if you want to manage MySQL or PostgreSQL yourself, it’s nothing you cannot do. Kamal doesn’t know about databases per se, but will happily deploy any additional services on hosts you specify.
To deploy PostgreSQL, we’ll use the official Docker image, specify the host, port, environment variables, custom entrypoint, and location for the raw database data. We’ll need to provide the database user, name and a password:
# config/deploy.yml
...
accessories:
db:
image: postgres:15
host: 165.227.160.215
port: 5432
env:
clear:
POSTGRES_USER: "myapp"
POSTGRES_DB: 'myapp_production'
secret:
- POSTGRES_PASSWORD
files:
- config/init.sql:/docker-entrypoint-initdb.d/setup.sql
directories:
- data:/var/lib/postgresql/data
The files
directive lets us provide our own entrypoint. The config/init.sql
needs to create the database as expected from the config/database.yml
configuration:
# config/init.sql
CREATE DATABASE myapp_production;
And on the Rails side:
# config/database.yml
production:
<<: *default
username: myapp
password: <%= ENV["POSTGRES_PASSWORD"] %>
database: myapp_production
host: <%= ENV["DB_HOST"] %>
Put your POSTGRES_PASSWORD
inside .env
. Notice that we are not using the DATABASE_URL
string so make sure it’s not set.
In my deployment book I suggest using the peer authentication for better security (no passwords) and direct UNIX sockets for better performance on a single host, but this setup will become handy later when scaling up to multiple hosts.
Also remember that you’ll now have to make your own backups of PostgreSQL databases located on /myapp-db/data
which will be automatically created for you if specified as a Kamal directory.
Redis
Redis is a bit tricky at first. The default image accepts connections from anywhere so if you would just expose the port without providing a specific bind
directive, you would let anyone to connect. Some people might start with using just a password, but I recommend to keep things more secure if you can.
We’ll use Kamal roles
to make Redis available for the application, pass the --requirepass
to require a password and depend on the private Docker network from before. Since we’ll pass the password directly as a custom image command, we’ll take the advantage of Kamal processing the file as ERB template:
# config/deploy.yml
...
accessories:
db:
...
redis:
image: redis:latest
roles:
- web
- job
cmd: "redis-server --requirepass <%= File.read('/path/to/redis/password') %>"
volumes:
- /var/lib/redis:/data
options:
network: "private"
If the password is secret
, your Redis connection URL will become redis://:secret@myapp-redis:6379/1
. Save it as a secret REDIS_URL
. The port stays 6379 in this case as it’s specified in the image.
They are more ways to provide custom configuration, either with a custom Dockerfile, or with envs and files as we did with PostgreSQL. However, if we want to stick with the official image, the easiest way to not expose the port to public is by leveraging the private network and setting the password with --requirepass
.
On the Rails side make sure you are setting up REDIS_URL
everywhere where it’s required. In case of Sidekiq, you likely need to set it both for client and server:
# app/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = {
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
}
end
Sidekiq.configure_client do |config|
config.redis = {
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
}
end
Assets
Since we don’t have a dedicated file server in this setup, we’ll let Rails handle assets too. If you used the official Rails Dockerfile, assets should be part of your image already and the only remaining thing is to recheck the Rails configuration:
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
Make sure that the public_file_server
configuration is enabled. In the default setup we’ll just have to set RAILS_SERVE_STATIC_FILES
environment variable in the Kamal envs section and things will start to work.
Active Storage
A lot of people will opt-in for an object storage, but starting out with a disk service will save you some setup time and costs. Active Storage local storage only really need an access to a directory on a server. Assuming you set up the /storage
directory beforehand, you only need to mount this path in Kamal:
# config/deploy.yml
image: ...
volumes:
- "/storage:/rails/storage"
servers:
...
Remember that the Rails root is dependening on the application location inside the Docker image. In the official Rails Dockerfile that’s /rails
but chances are you have there /app
or something similar.
The ./storage
path can be configured in config/storage.yml
:
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
Also recheck your Rails configuration:
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
Envs
At this point you should have all the envs you need. In the env
section, put the public configuration under clear
and private one under secret
:
env:
clear:
HOSTNAME: imagebinary.com
DB_HOST: 10.135.0.3
RAILS_SERVE_STATIC_FILES: true
RAILS_LOG_TO_STDOUT: true
secret:
- KAMAL_REGISTRY_PASSWORD
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
- REDIS_URL
Secret values will be automatically populated from an .env
file so make sure all the value are there.
I am also setting RAILS_LOG_TO_STDOUT
to true
since we want to access logs from Docker and kamal app logs
.
Hooks
Kamal let’s us extend the default deployment flow with the following hooks:
$ ls .kamal/hooks
post-deploy.sample pre-build.sample pre-connect.sample pre-deploy.sample
The unfortunate thing is that we need a one-time hook running on the server after Docker is installed.
The closest thing to use is the pre-deploy
hook, but we can also do these manual steps after Kamal installs Docker on the servers:
$ ssh root@165.227.160.215
root# mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json
root# mkdir /storage -p && chmod a+rwx /storage
root# docker network create -d bridge private
This sets up the directory for Let’s Encrypt, an example /storage
disk space and the Docker private network called private
.
Deploy
Once you went through the manual steps from the beginning and wrote your config/deploy.yml
file, you are ready to deploy with:
$ kamal setup
Remember that if you haven’t created the Let’s Encrypt directory or the Docker network, things will break. But even if they do you can ssh
into the machine, run whatever you need and continue with kamal deploy
.
Later you can update your environments with:
$ # won't restart services
$ kamal env push
And redeploy with:
$ kamal deploy
If there is something wrong, check the logs:
$ kamal traefik logs
$ kamal app logs
Auxiliary services must be deleted and rebooted:
$ kamal accessory delete redis
$ kamal accessory boot redis
Remember it’s just Docker so ssh
into the machine and run your usual Docker commands.
Notes
Please take this post as an inspiration rather than a hardened example:
-
You might not want Rails to serve static assets. Create an NGINX container or introduce a CDN as Traefik is pure load balancer and won’t serve your files.
-
You might not want to expose your database to the outside world. I used public setup for the database and private for Redis as it shows the two different approaches.
-
Your mount volumes should be ideally accessed by web and job containers only.
-
You don’t want to use
root
user to access your VM. I always give sudo privileges to a different user and disable logging asroot
.
I welcome your feedback and will update the post accordingly.