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 versionshould answer; if onlydocker-composeworks 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.
Clone and start
bash $ git clone https://github.com/AmJaradat01/burnerbyte.git$ cd burnerbyte$ docker compose up -dThe first run builds the API, SMTP and frontend images from source, so expect a few minutes. After that,
docker compose up -dis seconds.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 apiPostgres, 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
upfail for anyone already running those locally.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 checkdocker 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.
$ 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 --buildJWT_SECRET fails loudly — the API will not start on the placeholder. ENCRYPTION_KEY fails quietly: unset, the platform works perfectly and stores every credential you type into the admin UI unencrypted. Set it before you configure SSO or outbound SMTP, not after, because values already written in plain text are not retroactively encrypted.Also change before exposure
| Variable | Why |
|---|---|
| POSTGRES_PASSWORD | Defaults to burnerbyte. Published in the repository. |
| REDIS_PASSWORD | Defaults to burnerbyte-redis. Same problem. |
| MINIO_ROOT_USER / MINIO_ROOT_PASSWORD | Default to minioadmin / minioadmin. |
| SMTP_HOSTNAME | Defaults 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_URL | Drive 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.
cmd/api with no database configured boots a token-gated web installer instead of exiting. It collects the database URL, Redis URL and secrets, verifies the connections, writes config.yaml and restarts into normal operation. Docker Compose sets DATABASE_URL and REDIS_URL in the environment, so it never appears on this path.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. Case-insensitive, trailing dot stripped,# no subdomain fallback. Get it wrong and nothing ever verifies.SMTP_HOSTNAME=mail.example.com$ docker compose up -d smtpdSMTPD_PORT=2525 and redirect 25 to it on the host. The Reverse Proxy reference has the iptables rule.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, 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.
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.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.
.env is shared with the run-from-source workflow, where DATABASE_URL points at localhost — and inside a container, localhost is the container itself. The distinct names are what keep one file usable for both. It looks like an inconsistency until you hit the failure it prevents.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
# 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 imagesOne 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
$ 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 -20Take a database dump first (below). Migrations are forward-only in practice; rolling back a release means restoring the dump, not reversing the schema.
Backups
# 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
| Symptom | Cause |
|---|---|
| API exits immediately | JWT_SECRET is missing, shorter than 32 characters, or still the repository placeholder. The refusal is deliberate. |
| Domain never verifies | The 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 arrives | Port 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 TLS | The 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 console | BB_CORS_ALLOWED_ORIGINS derives from FRONTEND_URL. Changing the frontend port or host without updating it blocks every browser request. |
| Attachment 404s intermittently | More than one API or SMTP replica on the local-filesystem fallback. Configure real object storage. |
| Rate limits hitting everyone at once | Running 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.