Skip to content
BurnerByte

Docker Setup

Bring up the whole stack with Docker Compose — Postgres, Redis, MinIO, migrations, both Go binaries and the frontend — plus ports, volumes and health checks.

Full Stack with Docker Compose

The included docker-compose.yml runs the entire stack with no configuration file at all — every setting has a working default and the images boot purely from environment variables:

bash
docker compose up -d

This starts seven services — three published to the host, three internal, plus a one-shot migration job:

ServiceHost portDescription
frontend3000Next.js UI
api8080Go API server + workers
smtpd2525SMTP inbound server
postgres—PostgreSQL 16 database
redis—Redis 7 cache
minio—S3-compatible object storage
migrate—Applies schema migrations, then exits
Note

Customizing Ports and Credentials

All ports and credentials are configurable via environment variables. Create a .env file:

.env
# DatabasePOSTGRES_USER=burnerbytePOSTGRES_PASSWORD=a-strong-passwordPOSTGRES_DB=burnerbytePOSTGRES_PORT=5432        # dev overlay only — the base stack publishes no DB port # RedisREDIS_PORT=6379           # dev overlay onlyREDIS_PASSWORD=a-strong-redis-password # MinIOMINIO_ROOT_USER=minioadminMINIO_ROOT_PASSWORD=a-strong-passwordMINIO_BUCKET=burnerbyteMINIO_PORT=9000           # dev overlay onlyMINIO_CONSOLE_PORT=9001   # dev overlay only # Service portsAPI_PORT=8080SMTPD_PORT=2525FRONTEND_PORT=3000 # ApplicationJWT_SECRET=change-me-to-a-random-64-char-stringFRONTEND_URL=http://localhost:3000API_BASE_URL=http://localhost:8080   # baked into the frontend image at build timeSMTP_HOSTNAME=mail.example.com        # BB_SMTP_HOSTNAME on both api and smtpdWS_BASE_URL=ws://localhost:8080       # baked into the frontend image; wss:// behind TLSCORS_ALLOWED_ORIGINS=http://localhost:3000  # CORS + WebSocket origins; defaults to FRONTEND_URL

Infrastructure Only

To run just the infrastructure (database, cache, storage) while developing locally:

bash
make docker-infra# equivalently:# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres redis minio

docker-compose.dev.yml is the overlay that publishes the infrastructure ports to the host; without it those services stay on the internal network only.

Then run the API and frontend locally with make run-api and cd web && pnpm dev.

Careful

Using a Managed Database

The bundled postgres and redis services are a convenience, not a requirement. To run against a managed instance, set these in .env:

bash
EXTERNAL_DATABASE_URL=postgres://user:[email protected]:5432/burnerbyte?sslmode=requireEXTERNAL_REDIS_URL=rediss://:[email protected]:6380

api, smtpd and the one-shot migrate job all honour them, so the schema is applied to your database rather than the bundled one. The bundled containers then go unused — start only what you need with docker compose up -d api smtpd frontend.

Careful

Health Checks

Postgres, Redis, MinIO and the API define health checks. The API and SMTP servers wait for healthy database, Redis and MinIO and for the one-shot migrate job to complete successfully; the frontend waits for a healthy api.

Verify everything is running:

bash
# API healthcurl http://localhost:8080/healthz # Readiness (checks DB + Redis)curl http://localhost:8080/readyz # MinIO console (requires the dev overlay — see "Infrastructure Only")open http://localhost:9001

Volumes

Data is persisted in Docker volumes:

  • pgdata — PostgreSQL data
  • redisdata — Redis data
  • miniodata — MinIO object storage
  • attachments — local-filesystem attachment fallback, used when MinIO is unreachable at boot

To reset everything: docker compose down -v

`-v` deletes all four volumes

Running Inside a Container

Docker inside an unprivileged LXC container — a Proxmox or Incus guest, for instance — needs two features enabled on the container, and the failures when they are missing do not point at them.

  • nesting lets the container mount the cgroup and /proc views a container runtime needs. Without it the Docker daemon fails to start, or starts and cannot run anything.
  • keyctl exposes the kernel keyring syscalls, which are blocked by default on an unprivileged container. Postgres fails on startup in ways that read as data corruption rather than as a missing capability — which is the symptom worth recognising, because it sends people looking in entirely the wrong place.

On Proxmox that is --features nesting=1,keyctl=1 at pct create, or pct set <id> --features nesting=1,keyctl=1 on a stopped container. Your hypervisor's own documentation is the authority on the rest; nothing else about running this in a container differs from running it on a host.

Before You Expose It

Two values decide whether this deployment is defensible, and the second one fails quietly:

bash
cp .env.example .envecho "JWT_SECRET=$(openssl rand -hex 32)" >> .envecho "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .envdocker compose up -d --build

JWT_SECRET fails loudly — the API will not start on the repository placeholder. ENCRYPTION_KEY unset means the platform works perfectly and stores every credential you type into the admin UI in plain text, with no error to tell you, and values already written are not retroactively encrypted.

The full checklist — including the default Postgres, Redis and MinIO passwords, CORS, trusted proxies and rate limits — is in Production Deployment.

Receiving Real Mail

Inside its container the SMTP daemon always listens on 2525 — it runs as a non-root user with all capabilities dropped, so it never binds a privileged port itself. What changes is the host port Compose publishes.

.env
# Publish the container's 2525 on the host's 25, which is where the internet# will try to deliver.SMTPD_PORT=25 # The API — not smtpd — verifies domains, and it compares each domain's MX# record against this exact hostname.SMTP_HOSTNAME=mail.example.com
bash
docker compose up -d smtpd
Careful

Once DNS is in place, prove the path end to end:

from another machine
swaks --to [email protected] --server mail.example.com:25

A 550 means the daemon is reachable and rejected the recipient — usually because no active inbox exists at that address, which is correct behaviour. A timeout means the port is not open. Create an inbox in the UI first, then send to that exact address.

Public URLs Are Build Args

The single most common way a working local stack turns into a broken public one, so it gets its own section.

The frontend is a Next.js app, and NEXT_PUBLIC_* values are inlined into the client bundle when the image is built. They are Docker build args. Terminating TLS at a reverse proxy is therefore not enough: an image built with ws:// keeps dialling ws:// from an https:// page, and the browser blocks it as mixed content.

The symptom is what makes this expensive: nothing errors. Sign-in works, pages load, the API answers. Mail simply stops appearing until you refresh, and the only evidence is a blocked mixed-content request in the browser console.

The values to set and the rebuild command are in Rebuild the Frontend for TLS.

Note

Upgrading

bash
cd burnerbytegit pull # The migrate job runs first and "up" is idempotent, so applying an# already-current schema is a no-op rather than an error.docker compose up -d --builddocker compose logs migrate | tail -20

Take a database dump first — see Backup and Restore. Migrations are forward-only in practice; rolling back a release means restoring the dump, not reversing the schema.

Upgrading covers the rest: reading the changelog for every release you are skipping, version skew during a rolling restart, and why make migrate-down is not the way back.

When It Does Not Work

Troubleshooting covers the failures this stack actually produces — mail not arriving, domains that never verify, WebSockets that stop after TLS, attachments that 404 intermittently, and rate limits that hit everyone at once.