Self-host with Docker
One settings script and one command give you a working install: Postgres, the schema, the API,
the billing worker, the dashboard, HTTPS through Caddy, and your first admin login. The host
needs Docker with Compose v2 and openssl. It needs no Node.js, pnpm or psql.
Install
Write the settings
git clone https://github.com/gox-ai/two-minute-billing.git
cd two-minute-billing/deploy/docker
./init-env.sh # production: asks for your two host names
./init-env.sh --local # or: this machine only, http://localhost
The script writes two files, both mode 600:
.env: the image version, your host names and the database passwords.app.env: the engine's settings, with a newAPI_KEY_SECRETandGATEWAY_CREDENTIALS_KEY.
It shows the first admin's password one time. Store it in a password manager.
Point DNS at the server
For production, create A (or AAAA) records for both host names, for example
billing.example.com (the dashboard) and api.billing.example.com (the API). Caddy gets the
certificates when it starts. Ports 80 and 443 must be open.
Start
docker compose up -d
The init service runs first and then exits: it creates the database roles, installs the
schema, loads the country reference data and creates your organization and admin. The API
starts after it, then the worker and the dashboard. Sign in at your dashboard host.
.env and app.env with your database. Without GATEWAY_CREDENTIALS_KEY the stored
gateway credentials cannot be read, and a new API_KEY_SECRET stops every API key.
init-env.sh refuses to overwrite either file for this reason.Automated installs
Every first-start value can come from the environment or a secret file instead of the prompts.
init reads them only while the database has no users, so a second start changes nothing.
| Setting | Use |
|---|---|
TMBILLING_INIT_ADMIN_EMAIL | The first owner. Unset: no organization is created. |
TMBILLING_INIT_ORG_NAME | The organization's name. |
TMBILLING_INIT_ADMIN_PASSWORD | Used only to create the owner. It never changes an existing password. Remove it after the first start. |
TMBILLING_INIT_API_KEY | Optional. An sk_live_… or sk_test_… key (32 or more characters after the prefix) to install as a write key, so your automation knows it in advance. |
Each secret also reads from a file: set <NAME>_FILE to the path of a Docker, Swarm or
Kubernetes secret instead of <NAME>. This works for the database URLs, APP_DB_PASSWORD,
API_KEY_SECRET, GATEWAY_CREDENTIALS_KEY, SMTP_PASSWORD, the object storage keys and the
two TMBILLING_INIT_* secrets.
A bootstrap key is hashed with API_KEY_SECRET. If you change that secret, every key stops
working, the bootstrap key too.
What runs
| Service | Image | Notes |
|---|---|---|
postgres | postgres:17 | Volume pgdata. Never published on a port. |
init | engine | One-shot. Migrations run under a database lock, so two at once are safe. |
api | engine | Port 4000 inside the stack. Liveness /healthz, readiness /readyz. |
worker | engine | The billing clock. Keep one. It gets 70 seconds to stop. |
dashboard | dashboard | Port 4100 inside the stack. It never connects to the database. |
proxy | caddy:2 | HTTPS for both hosts. The only service with published ports. |
Logs are JSON on stdout, kept by Docker's log driver (10 MB × 5 files a service). To also send
them to Google Cloud Logging, set CLOUD_LOGGING=TRUE and GOOGLE_CLOUD_PROJECT_ID in
app.env. A production install must state CLOUD_LOGGING as TRUE or FALSE.
Optional settings
Add these to app.env, then docker compose up -d:
- Email.
MAILER_PROVIDER=smtpwithSMTP_HOST,SMTP_PORT,SMTP_USERNAME,SMTP_PASSWORDandMAIL_FROM. Without it, emails are written to the API log. - Logo uploads. Any S3-compatible store:
OBJECT_STORE_ENDPOINT(for examplehttp://minio:9000),OBJECT_STORE_FORCE_PATH_STYLE=TRUEfor MinIO,R2_BUCKET_NAME,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEYandCDN_URL(the public URL of the bucket). Without them the upload controls are hidden. - Payment gateways are connected per business in the dashboard. They need no setting here.
The repository's .env.example documents every setting.
Upgrade
docker compose --profile backup run --rm backup # 1. back up
# 2. set TMBILLING_VERSION in .env to the new release
docker compose pull && docker compose up -d # 3. migrate, then restart
init applies the new migrations while the old API and worker still serve, then Compose
replaces the API and, last, the worker. There is no downgrade: to go back, restore the backup
and the previous version.
To build the images from source instead of pulling them, run docker compose build before
up.
Back up and restore
docker compose --profile backup run --rm backup writes a pg_dump custom-format file to
deploy/docker/backups/. Copy it off the server with .env and app.env.
To restore into an empty install:
docker compose up -d postgres
docker compose exec -T postgres pg_restore -U tmbilling -d tmbilling --clean --if-exists < backups/<file>.dump
docker compose up -d
Rehearse a restore on a second machine before you need one.
Other setups
- Managed Postgres. Remove the
postgresservice, and setMIGRATION_DATABASE_URL(the owner) andDATABASE_URLforinit,apiandworker. The owner needsCREATEROLEand must be able to grantBYPASSRLS. Where it cannot, setTMBILLING_APP_ROLE=externaland create thetmbilling_systemlogin yourself (scripts/sql/system-role.sqlhas the statement). - Behind Cloudflare or a load balancer. Add
trusted_proxiesto theCaddyfileand setTRUST_PROXY_HOPS=2inapp.env. - Your own reverse proxy. Remove the
proxyservice with an override file and proxy the dashboard host to port 4100 and the API host to port 4000.
What to alert on
Self-hosted installs have no alerting service. Watch these in your log tool:
- The worker container is unhealthy or restarts in a loop, or logs
worker_boot_failed,worker_fatal_errororshutdown_forced. tick_clock_gapwarnings: the billing clock did not run on time.stranded_lock_released: a worker stopped without its drain and a lock was recovered.api_start_failed,init_failedorschema_behind: a start was refused.
SDKs and tools
Use the typed TypeScript client for the billing API, or generate a client in your own language from the OpenAPI document that this reference is built from.
AI assistants (MCP)
Let Claude, Cursor or any MCP client read your billing data: customers, subscriptions, invoices, payments and metrics, with a read-only API key.