Deploy with Docker

Run self-hosted Quackback in production with Docker Compose (app, PostgreSQL, and Silo storage) or docker run: configuration, email, storage, upgrades, and rollback.

JM
James Morton
Written By James MortonLast updated 1 day ago

Run self-hosted Quackback in production with Docker Compose or a single docker run container. Docker handles dependencies, database setup, and migrations for you.

Prerequisites

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

You don't need Redis, Dragonfly, or pg_cron. PostgreSQL is the only datastore.

Note: The published image is multi-arch (linux/amd64 and 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).

Warning: 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-compatible storage, 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

Tip: 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://..." keeps the quotes and fails with ERR_INVALID_URL.

How the Docker image works

  • A multi-stage build compiles the app; the production stage runs as a non-root quackback user.
  • The entrypoint runs database migrations on every startup, then starts the server.
  • SEED_DATABASE=true populates demo data (optional).
  • Health endpoints: /api/health/live (process up) and /api/health/ready (database, migrations, and worker checks). See Health endpoints.

Tip: 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 builds the internal connection strings (DATABASE_URL, S3_*) from your secrets and wires them to the bundled services. 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 and 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

Note: Without email configuration, sign-in 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 Configure email delivery 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 <[email protected]>

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 <[email protected]>

Resend:

EMAIL_RESEND_API_KEY=re_xxxxxxxxxxxx
EMAIL_FROM=Quackback <[email protected]>

Sign-in providers and integrations (optional)

Sign-in providers (GitHub, Google, Discord, and others) and integrations (Slack and others) are configured in the admin UI, not with environment variables. See Set up OAuth providers and the environment variables reference.

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. Old MINIO_IMAGE_TAG and MC_IMAGE_TAG settings are ignored; remove them.

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 the S3_* variables 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 (with docker run, 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 Set up a reverse proxy 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. If your proxy sets one authoritative client-IP header (such as X-Real-IP), you can set TRUSTED_CLIENT_IP_HEADER instead.

Upgrade

Warning: 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 and image (set 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

The built-in healthcheck in docker-compose.prod.yml polls /api/health/ready.

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 background processing. See Scale with multiple replicas.

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 development 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 file (for example DATABASE_URL="postgres://..."). Remove the quotes: Docker keeps them literally.

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 with APP_PORT in .env (the container always listens on 3000 internally):

APP_PORT=8080

Security checklist

  1. Use strong secrets: generate them with openssl rand -base64 32.
  2. Enable HTTPS: always use TLS in production, through a reverse proxy.
  3. Don't expose datastores: the production Compose file 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 releases regularly.

Was this helpful?

Your feedback shapes what we write next.