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:
docker compose up -dThis starts seven services — three published to the host, three internal, plus a one-shot migration job:
| Service | Host port | Description |
|---|---|---|
frontend | 3000 | Next.js UI |
api | 8080 | Go API server + workers |
smtpd | 2525 | SMTP inbound server |
postgres | — | PostgreSQL 16 database |
redis | — | Redis 7 cache |
minio | — | S3-compatible object storage |
migrate | — | Applies schema migrations, then exits |
Postgres, Redis and MinIO publish no host ports. api and smtpd reach them
over the compose network, and binding 5432, 6379 or 9000 on the host would make
docker compose up fail for anyone already running those locally. Use the dev
overlay below when you need them reachable from the host.
Customizing Ports and Credentials
All ports and credentials are configurable via environment variables. Create a .env file:
# 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_URLInfrastructure Only
To run just the infrastructure (database, cache, storage) while developing locally:
make docker-infra# equivalently:# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres redis miniodocker-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.
make docker-up / make docker-down map to docker compose up -d / down and start or stop the entire stack (api, smtpd, and frontend included), not just infrastructure.
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:
EXTERNAL_DATABASE_URL=postgres://user:[email protected]:5432/burnerbyte?sslmode=requireEXTERNAL_REDIS_URL=rediss://:[email protected]:6380api, 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.
These are deliberately not called DATABASE_URL / REDIS_URL. That pair is
read by the binaries when you run them directly (make run-api) and points at
localhost; .env is shared between both modes, and localhost inside a
container resolves to the container itself.
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:
# 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:9001Volumes
Data is persisted in Docker volumes:
pgdata— PostgreSQL dataredisdata— Redis dataminiodata— MinIO object storageattachments— local-filesystem attachment fallback, used when MinIO is unreachable at boot
To reset everything: docker compose down -v
Every inbox, email, attachment and user account, plus the object store. There is
no prompt and no undo. docker compose down without the flag stops the stack
and keeps the data.
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.
nestinglets the container mount the cgroup and/procviews a container runtime needs. Without it the Docker daemon fails to start, or starts and cannot run anything.keyctlexposes 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:
cp .env.example .envecho "JWT_SECRET=$(openssl rand -hex 32)" >> .envecho "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .envdocker compose up -d --buildJWT_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.
# 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.comdocker compose up -d smtpdPublishing a port below 1024 usually needs the Docker daemon to run as root,
which is the default but not universal — rootless Docker will refuse. If you are
running rootless, either grant the daemon the capability or leave
SMTPD_PORT=2525 and redirect 25 to it on the host; the iptables rule is in
Reverse Proxy.
Once DNS is in place, prove the path end to end:
swaks --to [email protected] --server mail.example.com:25A 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.
The same trap catches port changes. Setting API_PORT=9090 without updating the
URLs produces a frontend calling a port nothing is listening on. The Compose
file falls the URLs through the port variables to soften this, but an explicit
API_BASE_URL wins over the fallback — so set both or neither.
Upgrading
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 -20Take 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.