---
title: "Self-host with Docker"
description: "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."
canonical_url: "https://twominutebilling.com/help/developers/getting-started/self-host-docker"
---
# 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

<steps>

### Write the settings

```bash
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

```bash
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.

</steps>

<warning>

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.

</warning>

## 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.

<table>
<thead>
  <tr>
    <th>
      Setting
    </th>
    
    <th>
      Use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        TMBILLING_INIT_ADMIN_EMAIL
      </code>
    </td>
    
    <td>
      The first owner. Unset: no organization is created.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TMBILLING_INIT_ORG_NAME
      </code>
    </td>
    
    <td>
      The organization's name.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TMBILLING_INIT_ADMIN_PASSWORD
      </code>
    </td>
    
    <td>
      Used only to create the owner. It never changes an existing password. Remove it after the first start.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TMBILLING_INIT_API_KEY
      </code>
    </td>
    
    <td>
      Optional. An <code>
        sk_live_…
      </code>
      
       or <code>
        sk_test_…
      </code>
      
       key (32 or more characters after the prefix) to install as a write key, so your automation knows it in advance.
    </td>
  </tr>
</tbody>
</table>

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

<table>
<thead>
  <tr>
    <th>
      Service
    </th>
    
    <th>
      Image
    </th>
    
    <th>
      Notes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        postgres
      </code>
    </td>
    
    <td>
      <code>
        postgres:17
      </code>
    </td>
    
    <td>
      Volume <code>
        pgdata
      </code>
      
      . Never published on a port.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        init
      </code>
    </td>
    
    <td>
      engine
    </td>
    
    <td>
      One-shot. Migrations run under a database lock, so two at once are safe.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        api
      </code>
    </td>
    
    <td>
      engine
    </td>
    
    <td>
      Port 4000 inside the stack. Liveness <code>
        /healthz
      </code>
      
      , readiness <code>
        /readyz
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        worker
      </code>
    </td>
    
    <td>
      engine
    </td>
    
    <td>
      The billing clock. Keep one. It gets 70 seconds to stop.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        dashboard
      </code>
    </td>
    
    <td>
      dashboard
    </td>
    
    <td>
      Port 4100 inside the stack. It never connects to the database.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        proxy
      </code>
    </td>
    
    <td>
      <code>
        caddy:2
      </code>
    </td>
    
    <td>
      HTTPS for both hosts. The only service with published ports.
    </td>
  </tr>
</tbody>
</table>

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`.

<note>

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.

</note>

## 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

```bash
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:

```bash
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.


## Sitemap

See the full [sitemap](https://twominutebilling.com/sitemap.md) for all pages.
