How to deploy Rails with Kamal, PostgreSQL, Sidekiq and Backups on a single host
Guillaume Briday
7 minutes
In the previous post, How to deploy Rails with Kamal and SSL certificate on any VPS, we saw how to deploy a simple Rails application with Kamal and Docker.
But it was intentionally very simple and even if it might be enough for some applications like https://github.com/guillaumebriday/modern-datatables, it does not really reflect how you want to deploy your application in real life.
In real life, you (probably) want to use:
- PostgreSQL instead of SQLite.
- Backup (and restore) your database.
- Use background jobs with Sidekiq (or another queue system).
- Use Redis for cache.
In this example, we use Ruby on Rails, but the process would be similar with Laravel, Django or any other framework, adapt according to your needs.
So, let's see how to do it!
ℹ️ Most of the configuration and concept, will remain the same compared to the previous post, you should read it before this one.
1. Prepare the server
If you have new servers, you should take a look at my ansible playbook guillaumebriday/kamal-ansible-manager that will configure them properly for Kamal.
Unlike in the previous post, this time, we need to set up accessories and jobs.
Because we are running all of them on the same host, in order to allow them to communicate, we need to create a Docker Network.
More information here (in 🇫🇷): Docker : Les réseaux personnalisés.
Before starting, run this command, on your server:
$ docker network create --driver bridge private
I use the name private
but it can be anything, just stay consistent in your configuration.
2. Update the configuration files
The configuration is very similar to the previous one, but we need to adjust few things.
Servers
Let's configure our servers and don't forget to configure our new private
docker network:
servers:
web:
hosts:
- 192.168.0.1
labels:
traefik.http.routers.my_awesome_app.entrypoints: websecure
traefik.http.routers.my_awesome_app.rule: Host(`my_awesome_app.com`)
traefik.http.routers.my_awesome_app.tls.certresolver: letsencrypt
options:
network: 'private' # This is important!
job:
hosts:
- 192.168.0.1
cmd: bundle exec sidekiq -q default -q mailers
options:
network: 'private' # This is important!
Accessories
Now let's configure our accessories with correct directories
so the file are saved on the disk and won't be removed between deployments.
accessories:
db:
image: postgres:16
host: 192.168.0.1
# port: 5432 # Remove this line!
env:
clear:
POSTGRES_USER: 'my_awesome_app'
POSTGRES_DB: 'my_awesome_app_production' # The database will be created automatically on first boot.
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
options:
network: 'private' # This is important!
redis:
image: redis:7.0
host: 192.168.0.1
# port: 6379 # Remove this line!
directories:
- data:/data
options:
network: 'private' # This is important!
You don't need to expose your accessories ports, remove the port
key in their configuration, so they won't be accessible from outside Docker for nothing.
Because we use private Docker Network, we can use the name of the containers instead of exposing ports or using external IP address:
env:
clear:
RAILS_SERVE_STATIC_FILES: true
POSTGRES_USER: 'my_awesome_app'
POSTGRES_DB: 'my_awesome_app_production'
POSTGRES_HOST: 'my_awesome_app-db' # With the pattern: <service_name>-<accessory_name>
REDIS_URL: 'redis://my_awesome_app-redis:6379/0' # We need to override the REDIS_URL (used by Sidekiq and your cache_store), same pattern: <service_name>-<accessory_name>
secret:
- RAILS_MASTER_KEY
- SLACK_WEBHOOK_URL
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- CLOUDFRONT_ENDPOINT
- POSTGRES_PASSWORD
Don't forget to add your environment variables as well.
We need to update config/database.yml
file to match our configuration in deploy.yml
:
default: &default
adapter: postgresql
encoding: unicode
# For details on connection pooling, see Rails configuration guide
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
production:
<<: *default
username: <%= ENV["POSTGRES_USER"] %>
password: <%= ENV["POSTGRES_PASSWORD"] %>
database: <%= ENV["POSTGRES_DB"] %>
host: <%= ENV["POSTGRES_HOST"] %> # Because we don't use DATABASE_URL, we need to add this line.
And use Redis as cache store in config/environments/production.rb
:
config.cache_store = :redis_cache_store, { url: ENV.fetch('REDIS_URL') }
No need to update Sidekiq, it will use REDIS_URL
by default.
3. Update the Dockerfile
In the build
stage, you need to add libpq-dev
:
FROM base as build
# Install packages needed to build gems and node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential \
curl \
git \
libvips \
node-gyp \
pkg-config \
python-is-python3 \
libpq-dev # This is new
and add postgresql-client
in the final stage:
# Install packages needed for deployment
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl postgresql-client && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
Note that if you built your application with Rails 7.1 and database externally in case of failure or MySQL directly, it has already been configured for you, you don't have to change anything.
4. Backup to external a host or S3-compatible Object Storage service
It is crucial to properly back up our database externally in case of failure. Thanks to our Kamal-deployed infrastructure, we are not tied to a specific server provider, making migration so much easier!
Even if we use Docker volume on our db accessory, we can not just copy the folder and send it on another server. We need to build a proper dump with the command pg_dump that will make consistent backups even if the database is being used concurrently.
In my case, I will host the dump on a S3-compatible Object Storage service called Object Storage on Scaleway, but it can really be anywhere even on the cheapest and dumbest server using rsync, for example. It just has to be another server than the one your database is running on.
I will use the Docker image from eeshugerman/postgres-backup-s3 to dump and upload it on Object Storage.
ℹ️ You might be not comfortable using third-party scripts with your database and S3 credentials, and in this case I suggest you to write your own bash scripts. It's really just basic commands that dump and upload files in a crontab.
accessories:
# ...
s3_backup:
image: eeshugerman/postgres-backup-s3:16
host: 192.168.0.1
env:
clear:
SCHEDULE: '@daily'
BACKUP_KEEP_DAYS: 30
S3_REGION: your-s3-region
S3_BUCKET: your-s3-bucket
S3_PREFIX: backups
S3_ENDPOINT: https://your-s3-endpoint
POSTGRES_HOST: my_awesome_app-db
POSTGRES_DATABASE: my_awesome_app_production
POSTGRES_USER: my_awesome_app
secret:
- POSTGRES_PASSWORD
- S3_ACCESS_KEY_ID
- S3_SECRET_ACCESS_KEY
options:
network: 'private' # This is important!
Don't forget to add the environment variables in your .env
and adapt other variables according to your S3 configuration.
5. Healthcheck
By default, a health check request will occur every single second on your app to ensure that Traefik can send requests to it. Depending on your needs, you might want to change the frequency:
healthcheck:
interval: 5s
6. Put everything together
Let's put it all together and we should be good to go
See the complete deploy.yml
:
# Name of your application. Used to uniquely configure containers.
service: my_awesome_app
# Name of the container image.
image: my_awesome_app
# Deploy to these servers.
servers:
web:
hosts:
- 192.168.0.1
labels:
traefik.http.routers.my_awesome_app.entrypoints: websecure
traefik.http.routers.my_awesome_app.rule: Host(`my_awesome_app.com`)
traefik.http.routers.my_awesome_app.tls.certresolver: letsencrypt
options:
network: 'private'
job:
hosts:
- 192.168.0.1
cmd: bundle exec sidekiq -q default -q mailers
options:
network: 'private'
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: guillaumebriday
# Always use an access token rather than real password when possible.
password:
- KAMAL_REGISTRY_PASSWORD # Must be present in your `.env`.
# Inject ENV variables into containers (secrets come from .env).
# Remember to run `kamal env push` after making changes!
env:
clear:
RAILS_SERVE_STATIC_FILES: true
POSTGRES_USER: 'my_awesome_app'
POSTGRES_DB: 'my_awesome_app_production'
POSTGRES_HOST: 'my_awesome_app-db'
REDIS_URL: 'redis://my_awesome_app-redis:6379/0'
secret:
- RAILS_MASTER_KEY
- SLACK_WEBHOOK_URL
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- CLOUDFRONT_ENDPOINT
- POSTGRES_PASSWORD
# Use accessory services (secrets come from .env).
accessories:
db:
image: postgres:16
host: 192.168.0.1
env:
clear:
POSTGRES_USER: 'my_awesome_app'
POSTGRES_DB: 'my_awesome_app_production'
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
options:
network: 'private'
redis:
image: redis:7.0
host: 192.168.0.1
directories:
- data:/data
options:
network: 'private'
s3_backup:
image: eeshugerman/postgres-backup-s3:16
host: 192.168.0.1
env:
clear:
SCHEDULE: '@daily'
BACKUP_KEEP_DAYS: 30
S3_REGION: your-s3-region
S3_BUCKET: your-s3-bucket
S3_PREFIX: backups
S3_ENDPOINT: https://your-s3-endpoint
POSTGRES_HOST: my_awesome_app-db
POSTGRES_DATABASE: my_awesome_app_production
POSTGRES_USER: my_awesome_app
secret:
- POSTGRES_PASSWORD
- S3_ACCESS_KEY_ID
- S3_SECRET_ACCESS_KEY
options:
network: 'private'
# Configure custom arguments for Traefik
traefik:
options:
publish:
- '443:443'
volume:
- '/letsencrypt/acme.json:/letsencrypt/acme.json' # To save the configuration file.
network: 'private'
args:
entryPoints.web.address: ':80'
entryPoints.websecure.address: ':443'
entryPoints.web.http.redirections.entryPoint.to: websecure # We want to force https
entryPoints.web.http.redirections.entryPoint.scheme: https
entryPoints.web.http.redirections.entrypoint.permanent: true
certificatesResolvers.letsencrypt.acme.email: '[email protected]'
certificatesResolvers.letsencrypt.acme.storage: '/letsencrypt/acme.json'
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
healthcheck:
interval: 5s
7. Conclusion
It's easy as that, enjoy your reliable and cheap deploy! 🚀
📢 This post is part of a series on Kamal
- Easy, Perfect for side/small projects: How to deploy Rails with Kamal and SSL certificate on any VPS
- Medium: Perfect for most projects: How to deploy Rails with Kamal, PostgreSQL, Sidekiq and Backups on a single host
- Expert: Perfect for big projects: How to Deploy and Scale your Rails application with Kamal