Getting started

Self-host with Docker

Run Two Minute Billing on your own server with Docker Compose: Postgres, the API, the billing worker and the dashboard, with automatic HTTPS, a first admin and upgrades by version tag.

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 new API_KEY_SECRET and GATEWAY_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.

Back up .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.

SettingUse
TMBILLING_INIT_ADMIN_EMAILThe first owner. Unset: no organization is created.
TMBILLING_INIT_ORG_NAMEThe organization's name.
TMBILLING_INIT_ADMIN_PASSWORDUsed only to create the owner. It never changes an existing password. Remove it after the first start.
TMBILLING_INIT_API_KEYOptional. 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

ServiceImageNotes
postgrespostgres:17Volume pgdata. Never published on a port.
initengineOne-shot. Migrations run under a database lock, so two at once are safe.
apienginePort 4000 inside the stack. Liveness /healthz, readiness /readyz.
workerengineThe billing clock. Keep one. It gets 70 seconds to stop.
dashboarddashboardPort 4100 inside the stack. It never connects to the database.
proxycaddy:2HTTPS 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.

The worker's grace period is the rule that keeps billing safe on a restart: it finishes running jobs before it stops. If Docker stopped it sooner, its queue lock would stay held until the next worker proves the old one dead, which takes five minutes or more.

Optional settings

Add these to app.env, then docker compose up -d:

  • Email. MAILER_PROVIDER=smtp with SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD and MAIL_FROM. Without it, emails are written to the API log.
  • Logo uploads. Any S3-compatible store: OBJECT_STORE_ENDPOINT (for example http://minio:9000), OBJECT_STORE_FORCE_PATH_STYLE=TRUE for MinIO, R2_BUCKET_NAME, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and CDN_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 postgres service, and set MIGRATION_DATABASE_URL (the owner) and DATABASE_URL for init, api and worker. The owner needs CREATEROLE and must be able to grant BYPASSRLS. Where it cannot, set TMBILLING_APP_ROLE=external and create the tmbilling_system login yourself (scripts/sql/system-role.sql has the statement).
  • Behind Cloudflare or a load balancer. Add trusted_proxies to the Caddyfile and set TRUST_PROXY_HOPS=2 in app.env.
  • Your own reverse proxy. Remove the proxy service 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_error or shutdown_forced.
  • tick_clock_gap warnings: 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_failed or schema_behind: a start was refused.
Copyright © 2026