Skip to content

Deploy the server with Docker Compose

This is the single-host deployment: bugb-server and the Postgres that holds its state, brought up together by Compose. At the end you have a server answering on loopback and one bearer token for a developer to use.

For a cluster, see Deploy the server with Helm instead.

You need three things from Bugb, and the first two arrive together with your licence:

  • A registry credential for ghcr.io/bugb-technologies/bugb-server. The image is published to a private repository and this is what pulls it.
  • A deployment key (bugb_dk_live_…), issued at account.bugb.io, or a pinned licence token for a deployment that cannot reach the portal.
  • The deployment bundle, the deploy/ directory carrying docker-compose.yml, .env.example and verify-image.sh.

And on the host: Docker 23 or newer with Compose v2. Check with docker compose version.

Roughly 1 vCPU and 512 MB covers the server. Size Postgres to your finding volume, which is small: one row per verdict event.

  1. Log in and pull the image.

    Terminal window
    echo "$BUGB_REGISTRY_TOKEN" | docker login ghcr.io -u "$BUGB_REGISTRY_USER" --password-stdin
    docker pull ghcr.io/bugb-technologies/bugb-server:0.1.0

    Pin a version tag. There is deliberately no latest: a customer on a moving tag gets a different server, and therefore a different verdict lattice, on their next pull, chosen by nobody.

    Tag What it is
    0.1.0 (X.Y.Z) a release, written once and never rewritten. Use this one
    sha-abc1234 one specific build of main, immutable forever
    @sha256:… the digest, the only reference that cannot be repointed at all

    Then check what you pulled, before it goes anywhere near a client:

    Terminal window
    ./deploy/verify-image.sh ghcr.io/bugb-technologies/bugb-server:0.1.0

    It brings its own throwaway SQLite database, so it needs nothing but Docker and the image. It asserts that the engine and the dashboard are in the image, that the build channel really is release and stays release even with BUGB_BUILD_CHANNEL=dev set, and that a missing, malformed or foreign licence produces 402 everywhere with its own message. Its last five checks need a licence of their own, supplied in $BUGB_VERIFY_LICENSE; without one it still runs the gating checks and warns that the serving ones were skipped.

    If the runtime host cannot reach ghcr.io, pull somewhere that can, push into a registry it mirrors, and set BUGB_SERVER_IMAGE in the next step to that name.

  2. Write the environment file.

    Terminal window
    cp deploy/.env.example deploy/.env
    $EDITOR deploy/.env

    Compose reads deploy/.env on its own, because it sits beside the compose file. It is gitignored, it holds a database password, and it belongs on the deployment host and nowhere else.

    Four values need changing before this is safe to run:

    POSTGRES_PASSWORD=<openssl rand -base64 24>
    BUGB_SERVER_DSN=postgresql://bugb:<that same password>@postgres:5432/bugb
    BUGB_SERVER_BIND=127.0.0.1
    BUGB_DEPLOYMENT_KEY=bugb_dk_live_xxxxxxxx

    BUGB_DEPLOYMENT_KEY is the licence, and without it this deployment serves nothing. Set the deployment key or BUGB_LICENSE_TOKEN, never both. See License a deployment.

    BUGB_SERVER_IMAGE and BUGB_SERVER_TAG decide what Compose runs. They default to ghcr.io/bugb-technologies/bugb-server and 0.1.0, so leave them alone unless you mirrored the image somewhere else.

    The password is spelled twice and both copies must match. Postgres wants three variables and the server wants one DSN, and nothing reconciles them. A mismatch shows up as the server restarting in a loop with password authentication failed. If the password contains @ : / ? # or %, percent-encode it in the DSN, so @ becomes %40.

    postgres in that DSN is the Compose service name, which resolves on the Compose network. The port is 5432 inside the container whatever you publish on the host.

  3. Bring both services up.

    Terminal window
    docker compose -f deploy/docker-compose.yml pull
    docker compose -f deploy/docker-compose.yml up -d

    pull then up is the normal path: the image is published and there is nothing to build.

    The server waits for Postgres to report healthy before it starts. It applies its own schema during startup, so a server that starts against a database not yet accepting connections exits rather than retrying.

  4. Check both are healthy.

    Terminal window
    docker compose -f deploy/docker-compose.yml ps
    NAME STATUS
    bugb-postgres-1 Up 25 seconds (healthy)
    bugb-server-1 Up 25 seconds (healthy)

    Allow up to about 30 seconds. The healthcheck has a 30 second start period because the startup schema step is the slowest thing it does against a cold database.

There is no /health route. The shipped healthcheck reads the two routes that do exist, and the second one is the interesting one:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/openapi.json
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/v1/repos
200
401

A 401 on the second is the good answer. It means a request reached the auth check, which opens a database connection before it refuses you. If Postgres is unreachable that same request answers 500, and the container is marked unhealthy, which is correct: a server that can serve its schema but not its database is not healthy in any sense that matters.

A 402 on the second means the deployment holds no licence it accepts. docker compose logs server | head carries the sentence saying which of the four failures it is: no licence, a mangled token, a licence Bugb did not sign, or a licence for another product.

Run the same check by hand at any time:

Terminal window
docker compose -f deploy/docker-compose.yml exec server /usr/local/bin/bugb-healthcheck
healthy: serving on http://127.0.0.1:8000, database reachable

BUGB_SERVER_BIND defaults to 127.0.0.1 on purpose. Bearer tokens travel over this port in clear text.

Terminate TLS in front of it with nginx, Caddy, or your load balancer. Widen the bind to 0.0.0.0 only when that terminator is on another host, and only deliberately.

Every request carries a bearer token, and the token is what determines the tenant. A caller cannot assert its own tenancy in a request body, which is what makes the isolation hold.

Terminal window
docker compose -f deploy/docker-compose.yml exec server \
python scripts/mint_token.py --tenant acme --identity alice --role member
minted member token for alice@acme — shown once, not stored
bugb_ZRbsvgLqZdCncXcvGxvUOIEG3-KHjZMcy9i3YjdfUtU

The confirmation goes to stderr and the token to stdout, so $(...) captures only the token. Only its sha256 is stored, so there is no way to show it again. If it is lost, mint another.

Minting is a write, so it is licence-gated like every other write: on a deployment holding no licence this script refuses too, rather than handing out a credential nothing will accept. A seat is a distinct identity among a tenant’s non-revoked tokens, and the cap comes from the licence.

Roles run viewer < member < security_lead < admin. A developer or a CI job needs member, because viewer cannot push:

{"detail":"role 'viewer' cannot push events; member or above is required"}
HTTP 403

Mint one token per identity, and a separate one per CI pipeline, so revoking one does not revoke everyone. scripts/revoke_token.py takes --token-hash, --token, or --identity to void every token a principal holds.

Hand the token to a developer along with the server URL, and send them to Connect the CLI to a server.

The named volume bugb-pgdata is the state of record. The image is disposable; the volume is not.

Terminal window
docker compose -f deploy/docker-compose.yml exec -T postgres \
pg_dump -U bugb -d bugb --format=custom > "bugb-$(date +%F).dump"

--format=custom restores with pg_restore and can be restored selectively. Take one before every upgrade, and schedule one alongside whatever else you back up. Restore a dump somewhere disposable at least once: an untested backup is a hypothesis.

An upgrade is: back up, load the new image, recreate. The schema step is idempotent and runs on every boot, so there is no separate migration to run. Migrations are forward only, which means a backup is the only real rollback for a release that changes the schema.