How To Set Up A Postgres Database Accessory Container with Kamal 2 in Ruby on Rails
If you are confused by this guide or notice a mistake, please get in touch so I can fix it.
Kamal is one of my favourite parts of Ruby on Rails 8, but when you generate a new Rails project with postgres (using rails new <your-service-name> --database=postgresql
), this does not automatically set up a postgres accessory container correctly for Kamal 2. Fortunately, it’s just a few small changes to get it working. This is not meant to be a comprehensive introduction to Kamal, but if you follow along, you should have a working setup by the end of it.
You can either download these new template files and drop them into your project, or follow the instructions below. I recommend committing the Rails generated files first and carefully looking at the git diff to make sure the changes still make sense with newer versions than Rails 8.0.2, Postgres 15, and Kamal 2.6.1.
Sidenote: Why use postgres and not sqlite? I love the simplicity of sqlite3, but I kept getting errors due to simultaneous read/write operations, so I just use postgres by default.
Setting up your server for Kamal
This is outside the scope of this tutorial (I might write it up later if I ever do this again), but a quick summary is: Buy VPS hosting, install the most recent Ubuntu Long-Term Support, set up authorization using ssh keys, disable password login, create a deploy
user and add them to the docker
group, set up authorization using ssh key for that too, set up a firewall.
For more detail, this script from this tutorial looks good, but I have not tried and vetted it.
config/database.yml
In these files, replace all templates, such as <your-service-name>
with the hyphenated name of your Rails app, such as my-blog
.
Replace the production
part of your config/database.yml
file with this:
production:
primary: &primary_production
<<: *default
username: <%= ENV["POSTGRES_USER"] %>
database: <%= ENV["POSTGRES_DB"] %>
password: <%= ENV["POSTGRES_PASSWORD"] %>
host: <%= ENV["DB_HOST"] %>
port: <%= ENV["POSTGRES_DB_PORT"] %>
cache:
<<: *primary_production
database: <%= ENV["POSTGRES_DB"] %>_cache
migrations_paths: db/cache_migrate
queue:
<<: *primary_production
database: <%= ENV["POSTGRES_DB"] %>_queue
migrations_paths: db/queue_migrate
cable:
<<: *primary_production
database: <%= ENV["POSTGRES_DB"] %>_cable
migrations_paths: db/cable_migrate
config/deploy.yml
Now open the Kamal config file at config/deploy.yml
and define all those environment variables above by replacing the top of the env:
section with this:
env:
secret:
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
DB_HOST: <your-service-name>-postgres
POSTGRES_USER: <your-service-name>
POSTGRES_DB: <your-service-name>_production
And setup the postgres accessory by overriding the commented out section defining the db
accessory. This cannot access the env variables you defined for the main service container, so we need to define them again.
accessories:
postgres:
image: postgres:15
host: <your-server-ip>
# If you define a port here, that port will be accessible from the host machine.
# This means that you cannot use the same port for multiple services/accessories.
# This will cause issues if you have multiple services with a postgres accessory,
# and you will need to modify which port each accessory uses by modifying the number before the :.
# You can still access the database using `<your-service-name>-postgres` as the hostname and `5432` as the port
# regardless of which port you expose here.
# Warning: If you remove the `127.0.0.1` part, this port is exposed to the world.
# port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: <your-service-name>
secret:
- POSTGRES_PASSWORD
files:
- db/production_setup.sql:/docker-entrypoint-initdb.d/setup.sql
directories:
- data:/var/lib/postgresql/data
Bonus: If you followed the advice to use a dedicated deploy
user above, make sure to set it by looking for the commented out line starting with # ssh:
.
.kamal/secrets and credentials/postgres.key
Now we need to include the postgres password. I like to store this in a file, so add the following line to the .kamal/secrets
file:
POSTGRES_PASSWORD=$(cat config/credentials/postgres.key)
Create a new folder in config
called credentials
, and create a new file called postgres.key
in this new folder. The contents should just be the password you want to use for your default postgres user. Generate one using a password manager or similar.
These key files should not be in your Version Control, so include the line config/credentials/*
in your .gitignore file.
Bonus: I do not like the default way of fetching the registry password, so I put this in another key file at config/credentials/kamal_registry_password.key
. If you do not know how to get the registry token, scroll down to the bonus section for an explanation.
db/production_setup.sql
Create a file called production_setup.sql
in the db
folder and add a CREATE DATABASE
command for each server you use in production as listed in database.yml
, for example:
CREATE DATABASE <your-service-name>_production;
CREATE DATABASE <your-service-name>_production_cache;
CREATE DATABASE <your-service-name>_production_queue;
CREATE DATABASE <your-service-name>_production_cable;
Deploy
Before deploying, make sure the changes to your database.yml
file are committed, or the app will try to use the old version, and the deployment will fail.
You can setup Kamal, deploy the accessory, and deploy the app by running
kamal setup
If Kamal is already set up on the server you’re deploying to, you can also separately deploy the accessory by running kamal accessory boot postgres
and deploy the main service using kamal deploy
.
Did it not work? Check below for troubleshooting.
Troubleshooting
Troubleshooting: Permission denied during rails assets:precompile
If you get a permission denied error when precompiling assets during build, like this:
#17 [build 6/6] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
#17 0.078 /bin/sh: 1: ./bin/rails: Permission denied
#17 ERROR: process "/bin/sh -c SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile" did not complete successfully: exit code: 126
------
> [build 6/6] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile:
0.078 /bin/sh: 1: ./bin/rails: Permission denied
------
Dockerfile:49
--------------------
47 |
48 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY
49 | >>> RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
50 |
51 |
--------------------
ERROR: failed to solve: process "/bin/sh -c SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile" did not complete successfully: exit code: 126
docker stderr: Nothing written
Find the following line that runs precompile at roughly line 48:
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
and add the following statement before:
# Adjust binfiles to be executable on Linux
RUN chmod +x bin/* && \
sed -i "s/\r$//g" bin/* && \
sed -i 's/ruby\.exe$/ruby/' bin/*
You need to commit this change before it will work!
Troubleshooting: password authentication failed for user
If you get an error like
PG::ConnectionBad: connection to server at "172.18.0.9", port 5432 failed: FATAL: password authentication failed for user "<your-service-name>" (PG::ConnectionBad)
that likely means you are not using the same username and/or password in both your env
definition and accessory
definition. Double-check this. If you do notice a mistake in the accessory
definition, you need to remove the old version by running kamal accessory remove postgres
.
If you get the error:
ERROR (SSHKit::Command::Failed): Exception while executing on host <host-ip>: rm exit status: 1
rm stdout: Nothing written
rm stderr: rm: cannot remove '<your-service-name>-postgres/data': Permission denied
you need to SSH into the host server as root and find the <your-service-name>-postgres
folder in the deploy
user’s home folder ( cd /home/deploy
), and remove it by:
rm -rf <your-service-name>-postgres
Once you’ve done this, you can run the deployment again:
kamal setup
Troubleshooting: Cannot deploy another postgres accessory because the port 5432 is already used
If you set a full port mapping (if you follow this tutorial, you will not have done this unless you explicitly set a value for port:
in the accessory section of deploy.yml
) to expose the postgres accessory as a port on the server and then tried to deploy another postgres accessory container or already had a postgres server installed on the server (if this is you, ask yourself why you have a postgres database directly on the server and another one in a container, and see if you get a satisfying answer), you’ve likely been hit by a long, confusing error that ends in this:
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint \<your-service-name\>-postgres (\<long-string-of-numbers\>): Bind for 127.0.0.1:5432 failed: port is already allocated
To get around this, you need to change on which host machine port you expose the accessory. For example, if you had 127.0.0.1:5432:5432
, you can expose it on port 5433
instead using 127.0.0.1:5433:5432
. You can also still access the database from another container using <container-name>:5432
regardless of which host machine port you expose it to.
Bonus: How to manually create the user/role
When something goes wrong passing the POSTGRES_PASSWORD
and POSTGRES_USER
when creating the container, you might end up without a user. No problem, we’ll just create it manually:
Step 1: SSH into your Kamal server
ssh deploy@your-server-host
Step 2: Access the bash shell of your postgres accessory container. It needs to be booted and running for this to work, and the user you do this with needs to be root
or in the docker
group.
docker exec -it \<container-name\> bash
Step 3: Now you’re in the container shell, open up the postgres shell as the default/root user called postgres
:
psql -U postgres
Step 4: And now you can create the missing user with the same values as for the environment values you’re using in your deploy.yml
:
CREATE USER <POSTGRES_USER> WITH SUPERUSER PASSWORD '<POSTGRES_PASSWORD>' CREATEDB;
Warning: For production, you do not want to just have a user with superuser permissions, but be more fine-grained. This is a whole separate topic so I will not cover it here.
Bonus
Bonus: Getting a registry token
You can host your own registry, but the easiest way is to sign up for Docker Hub, go to ‘Account Settings’, and create a Personal Access Token with ‘Read & Write’ permissions. Copy this token into the kamal_registry_password.key
file you created earlier, or set it as an environment variable.
You can verify that everything works by running kamal registry login
.
Bonus: Updating the development part of database.yml
I like to define my development database credentials in the Rails credentials file. If you want to do this too, replace the default:
and development:
sections of the file with:
# Warning: This is different from the default, even though it does not affect production.
# I prefer storing my development database credentials in the Rails credentials, but use whatever method you prefer.
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 } %>
username: <%= Rails.application.credentials.dig(:database, :username) %>
password: <%= Rails.application.credentials.dig(:database, :password) %>
host: localhost
development:
<<: *default
database: <your-service-name>_development
# The TCP port the server listens on. Defaults to 5432.
# If your server runs on a different port number, change accordingly.
#port: 5432
# Schema search path. The server defaults to $user,public
#schema_search_path: myapp,sharedapp,public
# Minimum log levels, in increasing order:
# debug5, debug4, debug3, debug2, debug1,
# log, notice, warning, error, fatal, and panic
# Defaults to warning.
#min_messages: notice
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
and define the development and test database credentials in rails credentials:edit
like so:
database:
username: <local-database-username>
password: <local-database-password>
Subscribe to the Newsletter
Enjoyed this article? Subscribe to get notified when I publish new content.