Databases

This page shows practical database deployment patterns with Haloy and one simple backup strategy you can adapt.

How Haloy Handles Databases

For stateful services, start with preset: database. It applies safer defaults:

  • deployment_strategy: "replace"
  • naming_strategy: "static"
  • protected: true
  • image.history.strategy: "none"

A database has no domains, so other containers reach it by its static container name rather than through the proxy. Targets without domains always use the replace strategy.

Use named volumes for persistent storage and deploy databases explicitly:

haloy deploy -t app-postgres

Common Database Target Examples

The examples below use generic names and can live in the same haloy.yaml file.

PostgreSQL

server: haloy.example.com targets: app-postgres: preset: database image: repository: postgres:18 port: 5432 env: - name: POSTGRES_USER value: postgres - name: POSTGRES_PASSWORD from: env: POSTGRES_PASSWORD - name: POSTGRES_DB value: app volumes: - postgres-data:/var/lib/postgresql

MySQL

server: haloy.example.com targets: app-mysql: preset: database image: repository: mysql:8 port: 3306 env: - name: MYSQL_DATABASE value: app - name: MYSQL_USER value: app - name: MYSQL_PASSWORD from: env: MYSQL_PASSWORD - name: MYSQL_ROOT_PASSWORD from: env: MYSQL_ROOT_PASSWORD volumes: - mysql-data:/var/lib/mysql

MongoDB

targets: app-mongodb: preset: database image: repository: mongo:8 port: 27017 env: - name: MONGO_INITDB_ROOT_USERNAME value: root - name: MONGO_INITDB_ROOT_PASSWORD from: env: MONGO_INITDB_ROOT_PASSWORD volumes: - mongodb-data:/data/db

Redis

targets: app-redis: preset: service image: repository: redis:7 port: 6379 volumes: - redis-data:/data

Valkey

targets: app-valkey: preset: database image: repository: valkey/valkey:8 port: 6379 volumes: - valkey-data:/data

Image data paths differ by database image and version. Always confirm paths in each image’s documentation.

Backup Your Database

Haloy does not force a single backup workflow. It gives you the deployment primitives and stays out of the way so you can choose the right approach for your system.

Backup strategy is workload-specific. Haloy does not prescribe one path. A simple pattern is:

  1. Run your database as one target.
  2. Run a second target that executes scheduled backups.
  3. Upload backup artifacts to object storage (for example Cloudflare R2).

Example: PostgreSQL Backups to Cloudflare R2

This mirrors the same pattern used in production in this project:

  • a dedicated backup target
  • a backup container with postgresql-client-18 and rclone
  • supercronic for scheduling
  • one immediate backup on container start, then daily scheduled backups

Add a backup target that builds from your backup files:

env: - name: POSTGRES_USER value: postgres - name: POSTGRES_PASSWORD from: env: POSTGRES_PASSWORD - name: POSTGRES_DB value: app targets: app-postgres: preset: database image: repository: postgres:18 port: 5432 volumes: - postgres-data:/var/lib/postgresql app-postgres-backup: preset: service image: build_config: dockerfile: "./infrastructure/backup/Dockerfile" context: "./infrastructure/backup/" env: - name: POSTGRES_URL value: "postgresql://postgres:${POSTGRES_PASSWORD}@app-postgres:5432/app" - name: RCLONE_CONFIG_R2_BUCKET value: "app-db-backups" - name: RCLONE_CONFIG_R2_TYPE value: "s3" - name: RCLONE_CONFIG_R2_PROVIDER value: "Cloudflare" - name: RCLONE_CONFIG_R2_ACCESS_KEY_ID from: env: RCLONE_CONFIG_R2_ACCESS_KEY_ID - name: RCLONE_CONFIG_R2_SECRET_ACCESS_KEY from: env: RCLONE_CONFIG_R2_SECRET_ACCESS_KEY - name: RCLONE_CONFIG_R2_REGION value: "auto" - name: RCLONE_CONFIG_R2_NO_CHECK_BUCKET value: "true" - name: RCLONE_CONFIG_R2_ENDPOINT from: env: RCLONE_CONFIG_R2_ENDPOINT

R2 details to get right:

  • Endpoint format: https://<account-id>.r2.cloudflarestorage.com
  • Do not append the bucket name to the endpoint
  • Set bucket separately with RCLONE_CONFIG_R2_BUCKET
  • Use RCLONE_CONFIG_R2_NO_CHECK_BUCKET=true for bucket-scoped credentials

Backup Container Dockerfile (infrastructure/backup/Dockerfile)

FROM debian:13-slim ARG TARGETARCH ARG SUPERCRONIC_VERSION=v0.2.43 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt trixie-pgdg main" > /etc/apt/sources.list.d/pgdg.list && apt-get update && apt-get install -y --no-install-recommends postgresql-client-18 rclone && SUPERCRONIC_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${SUPERCRONIC_ARCH}" -o /usr/local/bin/supercronic && chmod +x /usr/local/bin/supercronic && apt-get purge -y curl gnupg && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY backup-db.sh /usr/local/bin/backup-db.sh COPY crontab /etc/backup-crontab RUN chmod +x /usr/local/bin/backup-db.sh CMD ["/bin/sh", "-c", "/usr/local/bin/backup-db.sh && exec supercronic /etc/backup-crontab"]

Cron Schedule (infrastructure/backup/crontab)

0 3 * * * /usr/local/bin/backup-db.sh

Backup Script (infrastructure/backup/backup-db.sh)

#!/usr/bin/env bash set -euo pipefail POSTGRES_URL="${POSTGRES_URL:?POSTGRES_URL is required}" R2_BUCKET="${RCLONE_CONFIG_R2_BUCKET:-${R2_BUCKET:-}}" : "${R2_BUCKET:?RCLONE_CONFIG_R2_BUCKET is required}" RETENTION_DAYS="${RETENTION_DAYS:-30}" BACKUP_DIR="${BACKUP_DIR:-/tmp/db-backups}" TIMESTAMP=$(date +%Y%m%d-%H%M%S) FILENAME="backup-${TIMESTAMP}.dump" FILEPATH="${BACKUP_DIR}/${FILENAME}" mkdir -p "$BACKUP_DIR" pg_dump "$POSTGRES_URL" -Fc -f "$FILEPATH" rclone copyto --s3-no-check-bucket "$FILEPATH" "r2:${R2_BUCKET}/${FILENAME}" rm "$FILEPATH" CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y%m%d) rclone lsf --s3-no-check-bucket "r2:${R2_BUCKET}/" | while read -r file; do if [[ "$file" =~ ^backup-([0-9]{8})-[0-9]{6}.dump$ ]]; then file_date="${BASH_REMATCH[1]}" if [[ "$file_date" < "$CUTOFF" ]]; then rclone deletefile --s3-no-check-bucket "r2:${R2_BUCKET}/${file}" fi fi done

Restore Example

rclone copy "r2:app-db-backups/backup-20260101-030000.dump" ./ pg_restore --clean --if-exists -d "postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app" backup-20260101-030000.dump

Operational Checklist

  1. Confirm a new backup object is uploaded on schedule.
  2. Test restore regularly on a non-production database.
  3. Configure retention (script cleanup and/or bucket lifecycle rules).
  4. Add alerting for failed backup jobs.

Next Steps

Stay updated on Haloy

Get notified about new docs, deployment patterns, and Haloy updates.