Docker

Use Docker for a repeatable local setup or a self-managed server. The included image is already configured to run FitGuard from /data/config.yaml.

Before you start: run go test ./... and go build ./... locally. Never put provider keys in the image or in Git.

The Dockerfile, annotated

The actual file at the repo root, in full, not a summary. Every non-obvious line has a comment explaining why it's there, so you can adapt the same pattern to a different stack:

# Pinned to match go.mod's toolchain directive — Go's own Docker images
# tag by exact version, so drifting this independently is how you get a
# build that works locally and fails in CI on a stdlib difference.
FROM golang:1.26-alpine AS build
WORKDIR /src

# go.mod/go.sum copied and downloaded before the rest of the source, so
# this layer only re-runs (and re-downloads every dependency) when a
# dependency actually changes, not on every source edit.
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# CGO_ENABLED=0: a static binary with no libc dependency, which is what
# lets the next stage be `scratch` (nothing installed, not even glibc)
# instead of needing a distro base image just to satisfy a dynamic link.
# -ldflags "-s -w" strips debug symbols; the binary works identically,
# just smaller — there's no in-container debugging happening in `scratch`.
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /fitguard ./cmd/fitguard

FROM scratch
# scratch has no trust store of its own. fitguard's entire job is calling
# OpenAI/Anthropic/Gemini/Groq/Together over HTTPS, so without this every
# upstream request fails TLS verification (x509: certificate signed by
# unknown authority) rather than the image just being minimal.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /fitguard /fitguard
EXPOSE 8787
# Split so `docker run fitguard init` (or any other subcommand) still
# works — ENTRYPOINT is the binary, CMD is only the *default* arguments,
# overridden by anything passed after the image name.
ENTRYPOINT ["/fitguard"]
CMD ["run", "--config", "/data/config.yaml"]

The pattern behind each decision, if you're writing your own for a different language: pin the exact toolchain version rather than a floating tag; copy dependency manifests before source so dependency layers cache independently of code changes; build a static/self-contained binary if your language supports it so the final stage can be minimal; keep ENTRYPOINT as just the binary so the image stays usable for one-off subcommands, not only the default server process.

docker-compose.yml

Also at the repo root, for local development or a self-managed server that wants Redis alongside it without installing anything separately:

# docker compose up — fitguard plus an optional Redis for the cache/budget
# backends. Redis isn't required (fitguard defaults to in-memory/local for
# both), it's included here because it's the one thing worth having ready
# before you need it: switching a live single-instance deployment to
# `budget.backend: redis` later means a second migration, not just a
# config edit, if Redis isn't already there to point at.
services:
  fitguard:
    build: .
    ports:
      - "8787:8787"
    volumes:
      # Writable, not :ro — the dashboard's Settings page saves back to
      # this file. See "Do not mount the config read-only" below.
      - ./config.yaml:/data/config.yaml
      # Named volume, not a bind mount: fitguard.db lives here, and a
      # named volume survives `docker compose down` (a bind mount would
      # too, but only if you remember the host path never gets deleted;
      # this removes that footgun entirely).
      - fitguard-data:/data
    environment:
      # Referenced from config.yaml as ${OPENAI_API_KEY} — never write
      # the real value into config.yaml itself. Put real secrets in a
      # .env file (gitignored) next to this compose file, not here.
      - OPENAI_API_KEY
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    # No exposed port to the host on purpose: only the fitguard service
    # needs to reach it, over the compose-internal network as `redis:6379`.

volumes:
  fitguard-data:
  redis-data:
echo "OPENAI_API_KEY=sk-..." > .env   # gitignored — never commit this
docker compose up
curl -fsS http://localhost:8787/healthz

To actually use Redis instead of just having it running, point config.yaml at the compose service by name, not localhost (containers on the same compose network reach each other by service name):

budget:
  backend: redis
  redis_url: redis://redis:6379/0

cache:
  backend: redis
  redis_url: redis://redis:6379/0

Already have a Dockerfile for your own app?

FitGuard is a separate service, not something you add into your app's existing image. If your app already has its own Dockerfile — Python, .NET, whatever — it stays exactly as it is. There's no version of this where you add a Go stage to it.

Two ways to run them together, depending on where your app deploys:

Same compose file, two independent services (needs FitGuard's repo checked out alongside your app's):

services:
  your-app:
    build: .                        # your existing Dockerfile, unchanged
    environment:
      - FITGUARD_URL=http://fitguard:8787/v1
      - FITGUARD_KEY=sk-guard-...
    depends_on:
      - fitguard

  fitguard:
    build: ../ai-cost-guard         # FitGuard's own repo, its own Dockerfile
    volumes:
      - ./fitguard-config.yaml:/data/config.yaml
      - fitguard-data:/data

volumes:
  fitguard-data:

Your app reaches it at http://fitguard:8787/v1 (compose's internal DNS). This is the usual code change from the migration guide, just pointed at another container instead of localhost.

Separate services on your platform (Render, Railway, and most PaaS support more than one service per project): add FitGuard as a second, independent service: its own repo connection, its own Dockerfile (the one from FitGuard's repo, not yours), then set the URL that service gets as FITGUARD_URL on your existing app's service. No compose file involved.

FitGuard doesn't publish a ready-built image to Docker Hub or GHCR yet, so build: pointing at its Dockerfile, or a second platform service built from its repo, are the two real options today — not image: fitguard:latest.

Run locally (without compose)

docker build -t fitguard .
docker run --rm -p 8787:8787 \
  -v "$PWD/config.yaml:/data/config.yaml" \
  -v fitguard-data:/data \
  fitguard
curl -fsS http://localhost:8787/healthz

The named fitguard-data volume preserves fitguard.db when the container is replaced. Keep secrets in environment variables referenced by the YAML rather than written into the file.

Do not mount the config read-only (:ro). Saving from the dashboard's Settings page rewrites config.yaml, so a read-only mount makes every settings change fail with a permission error. Mount it writable.

The same applies to FITGUARD_CONFIG: when the config comes from that variable it takes precedence over the file on every start, so dashboard settings changes apply to the running process but are silently discarded on the next restart. Use a mounted file if you want Settings-page edits to survive; use FITGUARD_CONFIG when the config is managed entirely through the platform's environment.

Production checklist

Next: See Persistence and operations for backups, Redis, TLS, and scaling.