Skip to content

Deploy with Docker

The fastest way to get Quackback into production. Docker handles dependencies, database setup, and migrations automatically.

Prerequisites

  • Docker Engine 20.10+
  • Docker Compose v2+
  • 1 GB RAM minimum (2 GB recommended)
  • PostgreSQL 14+ with pgvector 0.5.0+ (included in the compose stack, or bring your own)

The published image is multi-arch (linux/amd64 + linux/arm64), so it runs natively on Apple Silicon and ARM servers (AWS Graviton, Ampere, Raspberry Pi).

Quick Deploy

docker-compose.prod.yml is a self-contained, single-host stack: the Quackback app plus PostgreSQL and PGSTY Silo, a maintained fork of MinIO that provides S3-compatible storage. The app is the only service with a published port. The datastores stay on the internal network and the upload bucket is private.

# Clone the repository (the Postgres image is built locally with pgvector)
git clone https://github.com/QuackbackIO/quackback.git
cd quackback
 
# Create your environment file and fill in every value
cp .env.prod.example .env
# Generate secrets with: openssl rand -base64 32
 
# Start the stack
docker compose -f docker-compose.prod.yml up -d

Open http://localhost:3000 (or your BASE_URL behind a reverse proxy).

The repository's root docker-compose.yml is for local development only. It starts the datastores (with insecure defaults and a world-readable bucket) but no app service. Use docker-compose.prod.yml for self-hosting.

Use Docker Run (bring your own datastores)

If you already run managed PostgreSQL and S3, run the app container on its own:

docker run -d \
  --name quackback \
  -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:pass@host:5432/quackback" \
  -e SECRET_KEY="your-32-char-secret" \
  -e BASE_URL="https://feedback.yourcompany.com" \
  ghcr.io/quackbackio/quackback:latest

If you pass configuration with --env-file instead of -e, do not wrap values in quotes and do not add inline # comments on a value line. Docker's --env-file reads everything after = literally, so DATABASE_URL="postgres://..." is parsed with the quotes attached and fails with ERR_INVALID_URL.

How the Docker Image Works

The Quackback Docker image uses a multi-stage build:

  • Builder stage compiles the application and installs dependencies
  • Production stage runs as a non-root quackback user for security
  • Entrypoint (docker-entrypoint.sh) runs database migrations automatically on every startup, then starts the server
  • Optional seeding with SEED_DATABASE=true to populate demo data
  • Health endpoints at /api/health/live (process up) and /api/health/ready (database, migrations, and worker checks)

Because migrations run on every startup, a routine upgrade is pulling the new image and restarting. Set SKIP_MIGRATIONS=true if you run migrations out-of-band, for example in a Kubernetes pre-upgrade hook.

Configuration

With the compose stack you set everything in .env (copied from .env.prod.example). The compose file derives the internal connection strings (DATABASE_URL, S3_*) from your secrets and wires them to the bundled services automatically. You provide the secrets, not the URLs.

Required

# Public URL (must match your domain)
BASE_URL=https://feedback.yourcompany.com
 
# Auth/encryption key (32+ characters). The stack refuses to start without it.
# Generate with: openssl rand -base64 32
SECRET_KEY=
 
# Reverse proxies in front of the app: 0 = none, 1 = one proxy, 2 = CDN plus proxy
TRUSTED_PROXY_HOPS=0
# Or, if your proxy sets one client-IP header: TRUSTED_CLIENT_IP_HEADER=x-real-ip
 
# Bundled PostgreSQL
POSTGRES_USER=quackback
POSTGRES_PASSWORD=
POSTGRES_DB=quackback
 
# Bundled object storage (also used as the S3 access key / secret)
MINIO_ROOT_USER=quackback
MINIO_ROOT_PASSWORD=
S3_BUCKET=quackback
S3_REGION=us-east-1
 
# Image tag to run (pin to a release in production)
QUACKBACK_TAG=latest

Email

Without email configuration, OTP login codes are printed to the container logs. For production, configure one of the providers below.

Configure exactly one sending provider. If more than one is set, the app refuses to start and names the conflicting variables. See Email for details.

SMTP

EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=your-username
EMAIL_SMTP_PASS=your-password
EMAIL_FROM=Quackback <feedback@yourcompany.com>

Amazon SES (all three keys are required)

EMAIL_SES_ACCESS_KEY_ID=AKIA...
EMAIL_SES_SECRET_ACCESS_KEY=...
EMAIL_SES_REGION=us-east-1
EMAIL_FROM=Quackback <feedback@yourcompany.com>

Resend

EMAIL_RESEND_API_KEY=re_xxxxxxxxxxxx
EMAIL_FROM=Quackback <feedback@yourcompany.com>

Sign-in providers and integrations (optional)

OAuth providers (GitHub, Google, Discord, etc.) and integrations (Slack, etc.) are configured in the admin UI, not with environment variables. See the Environment Variables Reference for the full list.

Object Storage

The bundled storage is PGSTY Silo, a maintained fork of MinIO. It keeps the minio service name, the minio_data volume, and the MINIO_ROOT_USER / MINIO_ROOT_PASSWORD variables. Both Silo images are pinned by digest. To use a mirror or another release, set SILO_IMAGE and SILO_CLIENT_IMAGE to full image references.

The bucket has no public port, so files are served through the app's /api/storage route (S3_PROXY=true is set for you in the compose file). To use an external S3 provider (AWS S3, Cloudflare R2, Backblaze B2) instead, point S3_* at your provider and remove the minio and minio-init services from the compose file.

Database

Use the bundled PostgreSQL

The compose stack builds a PostgreSQL 18 image with pgvector, and the app runs migrations automatically on startup.

Use an external database

For a managed database such as AWS RDS or Google Cloud SQL:

  1. Create a PostgreSQL 14+ database with pgvector 0.5.0+ and pg_trgm available
  2. Use a direct or session-mode connection string, not a transaction-mode pooler (realtime uses LISTEN/NOTIFY)
  3. Point DATABASE_URL at it (via the Docker Run path, or by editing the compose file)

See System Requirements for the full list.

Migrations run automatically when the container starts. To run them manually:

docker run --rm \
  -e DATABASE_URL="your-external-db-url" \
  --entrypoint bun \
  ghcr.io/quackbackio/quackback:latest \
  /app/migrate.mjs

Reverse Proxy

In production, place a reverse proxy in front of Quackback for HTTPS. See the Reverse Proxy Configuration guide for Caddy, Nginx, and Traefik examples.

Behind a proxy, set TRUSTED_PROXY_HOPS=1 (or 2 for a CDN plus a proxy) so rate limits apply per visitor. See Set the trusted proxy hops.

Upgrade

Always back up your database and object storage before upgrading. Upgrading from 0.13? Follow Upgrade from 0.13 to 0.14 instead of the steps below.

# 1. Back up your database
docker compose -f docker-compose.prod.yml exec postgres \
  pg_dump -Fc -U "$POSTGRES_USER" "$POSTGRES_DB" > backup-$(date +%Y%m%d).dump
 
# 2. Pull the latest source + image (bump QUACKBACK_TAG in .env to pin a version)
git pull
docker compose -f docker-compose.prod.yml pull
 
# 3. Restart (migrations run automatically)
docker compose -f docker-compose.prod.yml up -d

Migrations run in order on startup and are safe to re-run, so the container handles schema updates without manual steps.

Rollback

Migrations can't be reversed, so rolling back means restoring the backup you took before the upgrade and running the previous image. Never point an older image at a database a newer version has migrated.

# Stop containers
docker compose -f docker-compose.prod.yml down
 
# Restore the backup
docker compose -f docker-compose.prod.yml up -d postgres
docker compose -f docker-compose.prod.yml exec -T postgres \
  pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean < backup-YYYYMMDD.dump
 
# Start the previous image (set QUACKBACK_TAG to the old version in .env first)
docker compose -f docker-compose.prod.yml up -d

Health Checks

# Liveness: is the process up?
curl http://localhost:3000/api/health/live
 
# Readiness: are the database, migrations, and workers all okay?
curl http://localhost:3000/api/health/ready

docker-compose.prod.yml's built-in healthcheck polls /api/health/ready. See /api/health for the full response shape.

Run a separate worker

The single-container default (QUACKBACK_ROLE=all) serves HTTP and runs background workers in one process. To scale HTTP and background processing independently, run the same image with QUACKBACK_ROLE=web for front-end replicas and QUACKBACK_ROLE=worker for a dedicated background-processing replica. See Scale with multiple replicas for the full guide.

Troubleshooting

Containers start but the app isn't reachable

Make sure you used docker-compose.prod.yml. A bare docker compose up -d uses the root docker-compose.yml, which only starts the dev datastores and no app:

docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs app

ERR_INVALID_URL on startup

A quoted value in an --env-file .env (e.g. DATABASE_URL="postgres://..."). Remove the quotes: Docker keeps them literally. See the Tip under Use Docker Run.

Database connection failed

  1. Verify PostgreSQL is healthy:
    docker compose -f docker-compose.prod.yml ps
  2. Test the connection:
    docker compose -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -c "SELECT 1"

Port already in use

Change the published port via APP_PORT in your .env (the container always listens on 3000 internally):

APP_PORT=8080

Security

Follow these practices for production deployments.

  1. Use strong secrets: generate with openssl rand -base64 32
  2. Enable HTTPS: always use TLS in production (via a reverse proxy)
  3. Don't expose datastores: the prod compose keeps PostgreSQL and object storage off public ports; keep it that way
  4. Set TRUSTED_PROXY_HOPS correctly: match the number of proxies in front of the app, and keep it 0 if clients can reach the app port directly
  5. Pin the image: set QUACKBACK_TAG to a specific version rather than latest
  6. Back up regularly: automate database and object-storage backups
  7. Keep updated: pull new images regularly

Next Steps