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 18+ (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, Dragonfly (Redis), and MinIO (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 for pg_cron + 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, Redis, 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 REDIS_URL="redis://your-redis-host:6379" \
  -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 endpoint at /api/health

Because migrations run on every startup, upgrading is as simple as pulling the latest image and restarting. No manual migration step needed. (Set SKIP_MIGRATIONS=true if you run migrations out-of-band, e.g. 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, REDIS_URL, S3_*) from your secrets and wires them to the bundled services automatically — so you only provide the secrets, not the URLs.

Required

# Public URL (must match your domain)
BASE_URL=https://feedback.yourcompany.com
 
# Auth/encryption key — generate with: openssl rand -base64 32
SECRET_KEY=
 
# Bundled PostgreSQL
POSTGRES_USER=quackback
POSTGRES_PASSWORD=
POSTGRES_DB=quackback
 
# Bundled MinIO (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.

SMTP (recommended)

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>

Resend

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

OAuth providers & integrations (optional)

OAuth providers (GitHub, Google, Discord, etc.) and integrations (Slack, etc.) are configured in the admin UI under Settings → Authentication / Integrations. See the Environment Variables Reference for the full list.

Object Storage

The bundled MinIO has no public port, so uploads are served through the app's /api/storage proxy (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 the required extensions (pgvector, pg_cron) and runs migrations automatically on startup.

Use an external database

For managed databases (AWS RDS, Supabase, Neon, etc.):

  1. Create a PostgreSQL 18+ database
  2. Enable the pgvector extension (and pg_cron if you want scheduled analytics refreshes)
  3. Point DATABASE_URL at it (via the Docker Run path, or by editing the compose file)

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

docker run --rm \
  -e DATABASE_URL="your-external-db-url" \
  ghcr.io/quackbackio/quackback:latest \
  bun /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.

Upgrade

Always back up your database before upgrading.

# 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

Drizzle migrations are sequential and safe to re-run, so the container handles schema updates on startup without any manual steps.

Rollback

# 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

# Application health
curl http://localhost:3000/api/health

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 Postgres/Dragonfly/MinIO off public ports; keep it that way
  4. Pin the image — set QUACKBACK_TAG to a specific version rather than latest
  5. Back up regularly — automate database (and object-storage) backups
  6. Keep updated — pull latest images regularly

Next Steps