Skip to content
BurnerByte

Docker Compose

The supported deployment path. One file brings up Postgres, Redis, MinIO, the migration job, both Go binaries and the frontend — and the same file runs in production, pointed at whatever infrastructure you prefer.

A clean checkout runs with no configuration at all. Every value has a working default, the bundled data services come up alongside the app, 49 migrations apply themselves, and the object-storage bucket is created on first boot. That is deliberate: the project wants the distance between git clone and a working inbox to be one command.

It is also development-grade, and the rest of this guide is about the gap between that and something you can leave running on the internet.

Prerequisites

  • Docker Engine 24 or newer with the Compose v2 plugin. docker compose version should answer; if only docker-compose works you are on v1 and should upgrade before starting.
  • A host with 2 CPU cores, 4 GB of memory and 20 GB of disk. The bundled Postgres, Redis and MinIO are the bulk of that; against managed services 2 GB is comfortable.
  • A domain you control the DNS for. Not needed to look around, required before a single real message can arrive.
  • Inbound port 25 reachable from the internet, if you want mail from outside your own network. Confirm this before anything else — see the deployment hub.

First run

This gets you a working instance on localhost. Nothing here is safe to expose; the next section fixes that.

  1. Clone and start

    bash
    $ git clone https://github.com/AmJaradat01/burnerbyte.git$ cd burnerbyte$ docker compose up -d

    The first run builds the API, SMTP and frontend images from source, so expect a few minutes. After that, docker compose up -d is seconds.

  2. Watch it come up

    bash
    $ docker compose ps # The migrate service is a one-shot job: it applies the schema and exits 0.# api and smtpd wait for it, so "migrate  Exited (0)" is success, not failure.$ docker compose logs -f api

    Postgres, Redis and MinIO deliberately publish no host ports. The API and SMTP daemon reach them over the Compose network, and binding 5432, 6379 or 9000 on the host would make up fail for anyone already running those locally.

  3. Open it

    http://localhost:3000. You will land in the setup wizard. If you get a connection refused, the frontend waits on the API’s healthcheck — give it the twenty-second start period and check docker compose logs api.

Seven services come up: postgres, redis and minio on the Compose network with no host ports, a one-shot migrate job, then api, smtpd and frontend. What each one is, which ports it publishes, and the volumes and health checks behind them are in the Docker Compose reference.

Secrets that matter

Two values decide whether this deployment is defensible. Generate both, put them in .env, and rebuild.

from the repository root
$ cp .env.example .env # 1. Token signing key. Under 32 characters and the API refuses to boot, which#    is how it avoids quietly signing tokens with the placeholder from the repo.$ echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env # 2. Encryption key for secrets at rest: SSO client secrets, outbound SMTP#    passwords, storage credentials. Leave it empty and all three sit in the#    database in plain text, with no error to tell you.$ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env $ docker compose up -d --build
ENCRYPTION_KEY has no safe default

Also change before exposure

VariableWhy
POSTGRES_PASSWORDDefaults to burnerbyte. Published in the repository.
REDIS_PASSWORDDefaults to burnerbyte-redis. Same problem.
MINIO_ROOT_USER / MINIO_ROOT_PASSWORDDefault to minioadmin / minioadmin.
SMTP_HOSTNAMEDefaults to mail.burnerbyte.local. Domain verification compares your MX record against this exact string, so no domain can verify until it is real.
FRONTEND_URL / API_BASE_URLDrive CORS, the WebSocket origin check and every link in an invite or password-reset email.

The setup wizard

First launch redirects to a one-time wizard. It is server-authoritative — you cannot skip past it by editing a URL — and it walks through seven steps, three of which are optional.

  • Platform owner. The first account, which becomes a system admin. System admins operate the platform without belonging to any organization.
  • Organization. The top-level tenant. Everything else — teams, domains, audit — hangs off it.
  • Outbound SMTP. Used for invites, email verification and password resets. Any relay works: Mailgun, SES, Postmark, or your own. This is outbound and entirely separate from the inbound SMTP daemon.
  • Domain. The first domain that will receive mail. You will need DNS records for it — that is the next guide but one.
  • Team, branding, invites. Optional, and all three are changeable later from settings.
Running the binaries directly?

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. Case-insensitive, trailing dot stripped,# no subdomain fallback. Get it wrong and nothing ever verifies.SMTP_HOSTNAME=mail.example.com
bash
$ docker compose up -d smtpd
Binding port 25 on the host

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, not runtime config

This is 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. Live mail silently stops arriving while everything else looks fine.

The values to set — FRONTEND_URL, API_BASE_URL, WS_BASE_URL — and the rebuild command are in Rebuild the Frontend for TLS. The part worth internalising is the symptom: 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.

Note

Managed Postgres and Redis

The bundled containers are a convenience, not a requirement. Set EXTERNAL_DATABASE_URL and EXTERNAL_REDIS_URL and the API, the SMTP daemon and the migration job all honour them, so the schema lands in your database. The full switch, including dropping the unused services, is in the Docker reference.

Why not just DATABASE_URL?

Object storage

With BB_MINIO_ENDPOINT unset, attachments fall back to the local filesystem under ./data/attachments, and the only signal is a warning in the log. On a single node that is fine. Above one replica it is a correctness bug: each API and SMTP instance keeps its own private copy, so an attachment written by one is a 404 from another, intermittently, depending on which instance answers.

The settings for pointing at external S3 are in Production Deployment. One thing that reference does not say: an endpoint that is set but unreachable costs a dial timeout on every boot before it falls back. Clear the value rather than leaving a stale one.

Operating it

bash
# Health. /healthz is liveness, /readyz also checks the dependencies.$ curl -fsS localhost:8080/healthz$ curl -fsS localhost:8080/readyz # Logs, one service or all of them$ docker compose logs -f api smtpd # Prometheus metrics. Answers loopback and RFC1918 clients only, so scrape# from inside the network — or set BB_METRICS_TOKEN and send a bearer token.$ curl -fsS localhost:8080/metrics | head # What version is running$ docker compose exec api /app/api --version 2>/dev/null || docker compose images

One API replica, for now

Request handling is stateless and scales horizontally, but every API process starts all seven background workers and there is no leader election and no flag to disable them. A second replica means duplicate DNS lookups, duplicate webhook retries, duplicate analytics rollups and duplicate expiry emails. Run exactly one api until that changes. The SMTP daemon has no such constraint and can run as many instances as you have ports for.

Upgrading

bash
$ cd burnerbyte$ git pull # Rebuild and restart. 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 --build # Confirm the schema moved$ docker compose logs migrate | tail -20

Take a database dump first (below). Migrations are forward-only in practice; rolling back a release means restoring the dump, not reversing the schema.

Backups

the three things worth backing up
# 1. PostgreSQL — the whole platform. Everything else is reconstructible.$ docker compose exec -T postgres pg_dump -U burnerbyte burnerbyte \    | gzip > burnerbyte-$(date +%F).sql.gz # 2. Attachments. Mirror the bucket, or the volume if you are on the#    local-filesystem fallback.$ docker run --rm -v burnerbyte_miniodata:/data -v "$PWD":/backup alpine \    tar czf /backup/minio-$(date +%F).tar.gz -C /data . # 3. Your .env. It holds the two keys that make the database readable.#    Losing ENCRYPTION_KEY means every stored credential is unrecoverable.

Redis needs no backup. It is a cache and a message bus; the reconciler worker rebuilds its state from PostgreSQL on startup.

Troubleshooting

SymptomCause
API exits immediatelyJWT_SECRET is missing, shorter than 32 characters, or still the repository placeholder. The refusal is deliberate.
Domain never verifiesThe MX target must equal SMTP_HOSTNAME exactly — no suffix or subdomain fallback. And it is the API that runs the check, so setting the hostname only on smtpd leaves the API on its default and nothing verifies.
Mail never arrivesPort 25 blocked upstream, MX pointing elsewhere, or no active inbox at that address. A 550 is the daemon working correctly; a timeout is the network.
Live updates stopped after TLSThe frontend image was built with ws://. Set WS_BASE_URL to wss://… and rebuild the frontend — a restart will not do it.
CORS errors in the consoleBB_CORS_ALLOWED_ORIGINS derives from FRONTEND_URL. Changing the frontend port or host without updating it blocks every browser request.
Attachment 404s intermittentlyMore than one API or SMTP replica on the local-filesystem fallback. Configure real object storage.
Rate limits hitting everyone at onceRunning behind a proxy without BB_RATE_LIMIT_TRUSTED_PROXIES set to its CIDR, so every request appears to come from the proxy. See the reverse proxy guide.

There is a longer troubleshooting reference in the documentation, and a full configuration reference covering every key. The raw sources are .env.example and config.example.yaml.