In my previous blog, we walked through deploying a Rails application with PostgreSQL using Kamal. In this post, we’ll add a simple, framework‑agnostic way to take database backups before each deployment and store them both locally and in Azure Blob Storage.
For quite some time, I’d been looking for a semi‑automatic solution to grab a backup right before deploy. I also try to avoid DB‑as‑a‑service lock‑in, so I usually run PostgreSQL myself as a Docker service (an “accessory” in Kamal terms).
Most early‑stage applications don’t need managed services like AWS RDS or Azure Database for PostgreSQL. If your product isn’t generating much revenue yet, that spend is often hard to justify. In practice, many teams reach for RDS mainly for two things: automatic backups and a nice performance dashboard.
Having complete control over database and backups solves two main challenges:
It offers a simple solution that we understand, control and can modify as per our need anytime
It saves money. (ask how important this is for a first time founder)
The steps below should work regardless of which web framework you’re using, as long as you can run a script on deploy.
Assumptions:
Working setup for kamal
SSH configured to connect to the server where application is deployed
$POSTGRES_PASSWORD is accessible to Kamal (Doesn’t require additional setup if you are already deploying the database service)
don’t forget to replace double quotes with straight quotes, substack modifies these quotes on paste
First, modify .kamal/hooks/pre-deploy file with this content:
#!/bin/bash
# Pre-deploy hook: Backup database before deployment
#
# This script:
# 1. Takes a .sql backup of the production database
# 2. Saves it to /tmp on the server
# 3. Downloads it to local ./backups directory
# 4. Clean up: remove backup from container and server /tmp
# 5. Uploads the backup to Azure Blob Storage
# 6. Cleans up old backups from Azure Blob Storage and local ./backups directory
set -e
# Log file for debugging (view with: cat .kamal/hooks/pre-deploy.log)
LOG_FILE=”.kamal/hooks/pre-deploy.log”
exec > >(tee -a “$LOG_FILE”) 2>&1
echo “”
echo “========== Pre-deploy started: $(date) ==========”
# Configuration (matches deploy.yml)
SERVER=”your-web-host-(ip/domain)”
SSH_USER=”your-ssh-username”
DB_CONTAINER=”your-database-container-name”
DB_USER=”your-database-username”
DB_NAME=”your-database-name”
LOCAL_BACKUP_DIR=”./backups”
# Generate timestamp for backup filename
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_FILENAME=”your-database-name_${TIMESTAMP}.sql”
CONTAINER_BACKUP_PATH=”/var/lib/postgresql/data/${BACKUP_FILENAME}”
SERVER_TMP_PATH=”/tmp/${BACKUP_FILENAME}”
echo “==> Starting pre-deploy database backup...”
# Ensure local backup directory exists
mkdir -p “$LOCAL_BACKUP_DIR”
# Get the database container ID
echo “==> Getting database container ID...”
CONTAINER_ID=$(ssh ${SSH_USER}@${SERVER} “docker ps --filter ‘name=${DB_CONTAINER}’ --format ‘{{.ID}}’” 2>/dev/null)
if [ -z “$CONTAINER_ID” ]; then
echo “WARNING: Database container not found. Skipping backup (first deploy?).”
exit 0
fi
echo “==> Found container: ${CONTAINER_ID}”
# Create backup inside the container
echo “==> Creating database backup: ${BACKUP_FILENAME}”
ssh ${SSH_USER}@${SERVER} “docker exec ${CONTAINER_ID} bash -c ‘PGPASSWORD=\”\$POSTGRES_PASSWORD\” pg_dump -U ${DB_USER} -h localhost -d ${DB_NAME} -Fp -f ${CONTAINER_BACKUP_PATH}’”
# Copy backup from container to server /tmp
echo “==> Copying backup to server /tmp...”
ssh ${SSH_USER}@${SERVER} “docker cp ${CONTAINER_ID}:${CONTAINER_BACKUP_PATH} ${SERVER_TMP_PATH}”
# Download backup to local machine
echo “==> Downloading backup to local ./backups...”
scp ${SSH_USER}@${SERVER}:${SERVER_TMP_PATH} ${LOCAL_BACKUP_DIR}/
# Clean up: remove backup from container and server /tmp
echo “==> Cleaning up temporary files...”
ssh ${SSH_USER}@${SERVER} “docker exec ${CONTAINER_ID} rm -f ${CONTAINER_BACKUP_PATH}”
ssh ${SSH_USER}@${SERVER} “rm -f ${SERVER_TMP_PATH}”
# Verify local backup exists
if [ -f “${LOCAL_BACKUP_DIR}/${BACKUP_FILENAME}” ]; then
BACKUP_SIZE=$(ls -lh “${LOCAL_BACKUP_DIR}/${BACKUP_FILENAME}” | awk ‘{print $5}’)
echo “==> Local backup complete: ${LOCAL_BACKUP_DIR}/${BACKUP_FILENAME} (${BACKUP_SIZE})”
else
echo “ERROR: Backup file not found locally!”
exit 1
fi
# Upload to Azure Blob Storage (using production credentials)
echo “==> Uploading backup to Azure Blob Storage...”
if RAILS_ENV=production bin/rails “db:backup:upload_to_azure[${LOCAL_BACKUP_DIR}/${BACKUP_FILENAME}]”; then
echo “==> Azure upload complete”
# Cleanup backups older than 3 months
echo “==> Running cleanup of old backups...”
RAILS_ENV=production bin/rails db:backup:cleanup_old || echo “WARNING: Cleanup failed, continuing...”
else
echo “WARNING: Azure upload failed, but local backup exists. Continuing...”
fi
echo “==> Pre-deploy backup finished. Continuing with deployment...”
echo “========== Pre-deploy completed: $(date) ==========”Note: you can choose to remove command that executes the rake task to upload file to Azure storage if you don’t need to use storage service. I am running the rake task in production environment, as I did not want to copy credentials to my development credentials file. Also, If you don’t use Azure (or want to test locally first), you can comment out or remove the upload_to_azure and cleanup_old calls.
Here is how the rake task responsible for uploading and cleaning the file
# frozen_string_literal: true
namespace :db do
namespace :backup do
RETENTION_MONTHS = 3
LOCAL_BACKUP_DIR = “./backups”
desc “Upload a database backup file to Azure Blob Storage (db-backups/ directory)”
task :upload_to_azure, [:file_path] => :environment do |_t, args|
file_path = args[:file_path]
abort “ERROR: File path required. Usage: rake db:backup:upload_to_azure[path/to/file.sql]” if file_path.blank?
abort “ERROR: File not found: #{file_path}” unless File.exist?(file_path)
credentials = Rails.application.credentials.azure_storage
abort “ERROR: Azure Storage credentials not configured” if credentials.blank?
require “azure/storage/blob”
client = Azure::Storage::Blob::BlobService.create(
storage_account_name: credentials[:storage_account_name],
storage_access_key: credentials[:storage_access_key]
)
container_name = credentials[:container]
blob_name = “db-backups/#{File.basename(file_path)}”
puts “==> Uploading to #{container_name}/#{blob_name}...”
File.open(file_path, “rb”) do |file|
client.create_block_blob(container_name, blob_name, file.read)
end
puts “==> Upload complete (#{(File.size(file_path) / 1024.0 / 1024.0).round(2)} MB)”
end
desc “List database backups in Azure Blob Storage”
task list_azure: :environment do
credentials = Rails.application.credentials.azure_storage
abort “ERROR: Azure Storage credentials not configured” if credentials.blank?
require “azure/storage/blob”
client = Azure::Storage::Blob::BlobService.create(
storage_account_name: credentials[:storage_account_name],
storage_access_key: credentials[:storage_access_key]
)
container_name = credentials[:container]
puts “==> Backups in #{container_name}/db-backups/:”
puts “-” * 60
blobs = client.list_blobs(container_name, prefix: “db-backups/”)
blobs.each do |blob|
size_mb = (blob.properties[:content_length].to_f / 1024 / 1024).round(2)
modified = blob.properties[:last_modified]
puts “#{blob.name.ljust(45)} #{size_mb.to_s.rjust(8)} MB #{modified}”
end
end
# Safety constants
BACKUP_PREFIX = “db-backups/”.freeze
BACKUP_PATTERN = /\Adb-backups\/your-database-name_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.sql\z/.freeze
LOCAL_PATTERN = /\Ayour-database-name_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.sql\z/.freeze
MIN_BACKUPS_TO_KEEP = 5 # Never delete if fewer than this many backups exist
desc “Delete backups older than #{RETENTION_MONTHS} months (with safety checks)”
task cleanup_old: :environment do
cutoff_date = RETENTION_MONTHS.months.ago
puts “==> Cleanup: Deleting backups older than #{cutoff_date.strftime(’%Y-%m-%d’)}”
puts “==> Safety: Keeping minimum #{MIN_BACKUPS_TO_KEEP} recent backups”
puts “==> Safety: Only deleting files matching pattern: #{BACKUP_PATTERN.inspect}”
puts “-” * 60
# Cleanup Azure
credentials = Rails.application.credentials.azure_storage
if credentials.present?
require “azure/storage/blob”
client = Azure::Storage::Blob::BlobService.create(
storage_account_name: credentials[:storage_account_name],
storage_access_key: credentials[:storage_access_key]
)
container_name = credentials[:container]
# SAFETY: Only list blobs with exact prefix, sorted by date (newest first)
all_backups = client.list_blobs(container_name, prefix: BACKUP_PREFIX)
.select { |b| b.name.match?(BACKUP_PATTERN) }
.sort_by { |b| b.properties[:last_modified] }
.reverse
puts “==> Found #{all_backups.size} backup(s) in Azure matching safe pattern”
# SAFETY: Never delete if we have fewer than minimum backups
if all_backups.size <= MIN_BACKUPS_TO_KEEP
puts “==> Skipping Azure cleanup: Only #{all_backups.size} backups exist (minimum: #{MIN_BACKUPS_TO_KEEP})”
else
# SAFETY: Keep the most recent MIN_BACKUPS_TO_KEEP, only consider older ones for deletion
candidates = all_backups.drop(MIN_BACKUPS_TO_KEEP)
deleted_azure = 0
candidates.each do |blob|
# SAFETY: Triple-check the blob name matches our exact pattern
unless blob.name.match?(BACKUP_PATTERN)
puts “ SKIPPED (invalid pattern): #{blob.name}”
next
end
# SAFETY: Only delete if older than cutoff
if blob.properties[:last_modified] < cutoff_date
client.delete_blob(container_name, blob.name)
puts “ Deleted from Azure: #{blob.name}”
deleted_azure += 1
end
end
puts “==> Deleted #{deleted_azure} old backup(s) from Azure”
end
else
puts “WARNING: Azure credentials not configured, skipping Azure cleanup”
end
# Cleanup local
deleted_local = 0
if Dir.exist?(LOCAL_BACKUP_DIR)
# SAFETY: Only match exact pattern
all_local = Dir.glob(”#{LOCAL_BACKUP_DIR}/your-database-name_*.sql”)
.select { |f| File.basename(f).match?(LOCAL_PATTERN) }
.sort_by { |f| File.mtime(f) }
.reverse
puts “==> Found #{all_local.size} backup(s) locally matching safe pattern”
if all_local.size <= MIN_BACKUPS_TO_KEEP
puts “==> Skipping local cleanup: Only #{all_local.size} backups exist (minimum: #{MIN_BACKUPS_TO_KEEP})”
else
candidates = all_local.drop(MIN_BACKUPS_TO_KEEP)
candidates.each do |file|
filename = File.basename(file)
# SAFETY: Triple-check filename matches pattern
unless filename.match?(LOCAL_PATTERN)
puts “ SKIPPED (invalid pattern): #{filename}”
next
end
if File.mtime(file) < cutoff_date
File.delete(file)
puts “ Deleted locally: #{filename}”
deleted_local += 1
end
end
end
end
puts “==> Deleted #{deleted_local} old backup(s) from local”
puts “==> Cleanup complete”
end
end
endafter updating values with your configuration you should run:
kamal deployHope following these steps reduces your stress and cost :)
GD!!

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.