How to deal with PostgreSQL Backups using Kamal
What is Kamal
Developed by 37signals (creators of Ruby on Rails, Basecamp and Hey), Kamal is a modern evolution of Capistrano for the world of containerised applications.
In addition to its hassle-free deployment on servers, whether virtual machines or bare metal, Kamal is known for its zero downtime deployments and rolling restarts. This ensures continuous service availability.
Originally built for Ruby on Rails, Kamal is versatile enough for any containerised workload. Written in Ruby, using YAML for configuration and Bash for Docker commands, Kamal is simple and efficient.
It avoids the complexity of tools like Kubernetes.
Its name is inspired by the ancient navigational instrument. It reflects its role in guiding different deployment strategies using familiar tools like Bash and Docker.
Prerequisites
For the purposes of this article, I'm assuming that you have a Kamal deployment for your application. I will be covering one in my blog in the near future.
I'm also assuming that you already have an S3-compatible object storage bucket. This could be Amazon S3, DigitalOcean Spaces, Scaleway Object Storage or others...
I use Scaleway Object Storage which is S3 compliant.
What's the concept
The approach I will describe uses the postgres-backup-s3 project, which is a combination of postgres-backup-s3 and postgres-restore-s3.
In short we're creating backups using another kamal accessory resource and pushing the backup to an S3 bucket.
Later on, the created backups can be restored using the same accessory service.
Create database backups and export to S3
Let's start with creating backups. Assuming you have a working Kamal deployment with a database accessory like mine:
accessories:
db:
image: postgres:14.0
host: 10.0.1.10
port: 5432
env:
secret:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- PGPASSWORD
directories:
- /mnt/db-storage-01/psql-data:/var/lib/postgresql/data
We can add the configuration for creating backups to the accessories
block
db_backup:
image: eeshugerman/postgres-backup-s3:15
host: 10.0.1.10
env:
clear:
SCHEDULE: '@daily' # optional
BACKUP_KEEP_DAYS: 7 # optional
S3_REGION: region
S3_ACCESS_KEY_ID: key
S3_SECRET_ACCESS_KEY: secret
S3_BUCKET: my-bucket
S3_ENDPOINT: https://bucket_endpoint
S3_PREFIX: backups
POSTGRES_HOST: 10.0.1.10
POSTGRES_DATABASE: dbname
POSTGRES_USER: user
secret:
- POSTGRES_PASSWORD
- PASSPHRASE # optional for encrypted backups
Let me explain the configuration.
You'll define an accessory called db_backup
using the docker image eeshugerman/postgres-backup-s3:15
. The ending 15
defines the PostgreSQL version in use.
The SCHEDULE
variable defines a GOcron based schedule for creating backups. The example uses @daily
which creates daily backups at midnight. Omitting this value results in no schedule.
If you pass BACKUP_KEEP_DAYS
, the tool will take care of cleaning up old backups. I will use a lifecycle rule for my terraform managed bucket to take care of old backups.
If PASSPHRASE
is specified, the backup will be encrypted using GPG.
All S3_*
variables are needed to access the previously created object storage bucket. Most likely you will want to pass this information using an .env
file as recommended in the kamal documentation.
The values required for the POSTGRES_*
variables are most likely already in use and just need to be passed a second time. Unfortunately there is no way to reuse already defined variables.
Configuration hints
If you're using S3-compatible object storage, as I am with Scaleway, you'll need to set the S3_ENDPOINT
variable. You'll get the endpoint from your provider after you create a bucket. In this case the bucket name will also be different. Basically the endpoint without the protocol part. You can read why in my other article in case you're getting some certificate verify failed
issues.
You don't need the endpoint if you are using AWS S3. There you can also use the bucket name as is.
I use a bastion/jump host to connect to hosts on my internal network. That's why the host IP is 10.0.1.10
. This may be a public IP for you.
Make sure that your database version and that of postgres-backup-s3
match.
Getting started with database backups
After defining the kamal accessory for backups, you can start the accessory with
kamal accessory boot db_backup -d <deployment-name>
Then you want to check the status
kamal accessory details -d <deployment-name>
You should see something like this
➜ medone-core git:(main) ✗ kamal accessory details db_backup -d nonprod
INFO [ac01a1a7] Running docker ps --filter label=service=med1-core-db_backup on 10.0.1.10
INFO [ac01a1a7] Finished in 2.809 seconds with exit status 0 (successful).
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
00bfbeb0e00f eeshugerman/postgres-backup-s3:14 "sh run.sh" About a minute ago Up About a minute med1-core-db_backup
Depending on the defined schedule the first backup will take some time to be taken for the first time. See GOcron schedules for what to expect.
Changing the accessory configuration
If you change the backup schedule or any other configuration parameter, you must ensure that the environment variables are updated. Execute
kamal envify -d <deployment-name>
before rebooting the accessory to load the new configuration
kamal accessory reboot db_backup -d <deployment-name>
Running on-demand backups
You can run on-demand backups using the following command
kamal accessory exec db_backup "sh backup.sh"
This will spin up a new container and run the database backup. If you want to reuse the already running db_backup
container, you can do so by adding the --reuse
parameter
kamal accessory exec db_backup "sh backup.sh" -d <deployment-name> --reuse
The log output will show
Launching command from existing container...
If you want to see the output generated by the backup.sh
script, you must run the command in an interactive shell using --interactive
.
kamal accessory exec db_backup "sh backup.sh" -d <deployment-name> --reuse --interactive
showing the output created in the container
Creating backup of med1_core_production database...
Uploading backup to med1-app-nonprod-med1-io-backup-01.s3.nl-ams.scw.cloud...
upload: ./db.dump to s3://med1-app-nonprod-med1-io-backup-01.s3.nl-ams.scw.cloud/backups/med1_core_production_2024-01-08T16:30:43.dump
Backup complete.
Connection to 10.0.1.10 closed.
Restoring a backup
To restore a previously created backup, run the following command
kamal accessory exec db_backup "sh restore.sh" -d <deployment-name>
No further changes to the accessory configuration are required. This will restore the latest backup found in the defined bucket.
NOTE: The project maintainer explains that if you have more than 1000 backups, the latest backup won't be restored because only one S3 ls
command is used to determine the backup to restore.
To restore a specific backup, use
kamal accessory exec db_backup "sh restore.sh <timestamp>" -d <deployment-name>
The format of the timestamp is %Y-%m-%dT%H:%M:%S
, for example 2024-01-08T16:46:53
.
You may want to check the files in your bucket for the correct timestamp so that you can restore them.
Caveat for restoring from S3-compatible object storage
Restoring the latest backup using any S3-compatible object storage fails because the postgres-backup-s3
container internally uses the aws s3 ls
command, which is not supported. At least not for Scaleway object storage. I have seen similar behaviour with Active Storage using object storage
➜ medone-core git:(main) ✗ kamal accessory exec db_backup "sh restore.sh" -d <deployment-name> --reuse --interactive
An error occurred (NoSuchKey) when calling the ListObjectsV2 operation: The specified key does not exist.
Fetching backup from S3...
fatal error: An error occurred (404) when calling the HeadObject operation: Key "backups/" does not exist
Restoring from backup...
pg_restore: error: could not open input file "db.dump": No such file or directory
rm: can't remove 'db.dump': No such file or directory
Restore complete.
Connection to 10.0.1.10 closed.
You need to run the restore command with a specific timestamp
. This works perfectly well. 🙌
➜ medone-core git:(main) ✗ kamal accessory exec db_backup "sh restore.sh 2024-01-08T16:46:53" -d <deployment-name> --reuse --interactive
Fetching backup from S3...
download: s3://<bucket-name>/backups/med1_core_production_2024-01-08T16:46:53.dump to ./db.dump
Restoring from backup...
Restore complete.
Connection to 10.0.1.10 closed.
Going further
While there are WAL (write-ahead logging) based approaches that essentially provide point-in-time recovery, the process described is perfectly adequate for my needs. For the majority of my applications I use a daily backup schedule.
For reference, here are two projects that I might look at in the future
This is it for now! 🏁
Having database backups and restores in place makes me happy and sleep well.
I hope you found this article useful and that you learned something new.
If you have any questions or feedback, didn't understand something, or found a mistake, please send me an email or drop me a note on twitter / x. I look forward to hearing from you.
Please subscribe to my blog if you'd like to receive future articles directly in your email. If you're already a subscriber, thank you.
Bonus section
While researching database backup issues, I came across an interesting approach that I'd like to share with you.
Use the following command from your local machine (or deployment host) to create an SQL dump of the defined database and save it locally in dump.sql
.
kamal accessory exec db --reuse --interactive --quiet "pg_dump -h 10.0.1.10 -d database_name -U your_user" -d nonprod > dump.sql
Replace 10.0.1.10
with the IP of your database host.
You will need to set the PGPASSWORD
environment variable on the database host which will be picked up by pg_dump
.
Such a simple SQL dump can be restored using the files
parameter provided for your database accessory.
Your database plugin configuration might look like this
accessories:
db:
image: postgres:16
host: myhost
port: 5432
env:
clear:
POSTGRES_USER: 'rails'
POSTGRES_DB: 'db'
secret:
- POSTGRES_PASSWORD
files:
- dump.sql:/docker-entrypoint-initdb.d/setup.sql
directories:
- data:/var/lib/postgresql/data
The interesting bit is to pass the local sql dump dump.sql
to be used by the postgres docker container as the init file to bootstrap your database.
files:
- dump.sql:/docker-entrypoint-initdb.d/setup.sql
This allows you to remove the current database
# Be sure to execute this, as it will completely destroy your current database
kamal accessory remove db
and start a new database using the SQL dump for the initial data
# this will upload dump.sql and bootstrap the new database with it
kamal accessory boot db