RSS Amplifier

GingerDev · Nov 29, 2025

Database backup using Kamal

0
Sign in to vote or save

Abhinay Kumar · GingerDev

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
end

after updating values with your configuration you should run:

kamal deploy

Hope following these steps reduces your stress and cost :)

GD!!

Read the original on gingerdev.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.