Deployment

FitGuard is one static binary with no required external services, so deploying it is mostly a question of where the config file and the database live. This page covers each target and the two failure modes that are easy to miss.

Two failure modes worth knowing up front, because neither one produces an error: an ephemeral filesystem wiping the request log on every deploy, and running more than one instance with the default budget backend. Fixes for both are below.

What goes where

The question every one of these deployment paths eventually raises: what actually gets committed, what stays on your machine, and what only ever exists as an environment variable? One table, before anything else:

FileGoes in git?Where it lives instead
DockerfileYes
config.example.yamlYesThe checked-in template, no real values
config.yaml (or any name you give your real config)No, neverYour own machine only, or the FITGUARD_CONFIG env var on platforms with no persistent disk
fitguard.dbNo, neverRuntime data, regenerated automatically and already gitignored
If you see a filename like config.production.yaml anywhere in these docs, that's just an example name for "your real config, for production." It is not something FitGuard looks for specifically. Call it whatever you want. It's never committed either way; it either stays on your laptop to be copied up by hand (VPS) or its contents go straight into an environment variable (Render/Railway/Heroku), covered below.

Generate your config locally first

fitguard init is interactive, so run it on your machine, not in a container build. It writes a config.yaml that you then ship.

fitguard init

Keep real provider keys out of the file itself by using ${ENV_VAR}, which is expanded when the config loads. That makes the file's content safe — no secret ever sits in it — which is why it's fine to paste into FITGUARD_CONFIG or copy to a server. It's still gitignored by default in this repo regardless, on purpose: even a secret-free config is credential-adjacent (provider names, user IDs, budgets), and "never committed" is a simpler rule to follow than "committed, but only if you double-check it first."

port: ${PORT}
providers:
  openai:
    api_key: ${OPENAI_API_KEY}
  anthropic:
    api_key: ${ANTHROPIC_API_KEY}
Only the braced ${VAR} form is expanded. A bare $VAR is left alone on purpose, so bcrypt hashes like $2a$10$... in your dashboard config don't get mangled.

Docker

The included Dockerfile builds a static CGO_ENABLED=0 binary and copies it, plus a CA certificate bundle, into a scratch image. The default command reads /data/config.yaml, so mount your config and data there.

docker build -t fitguard .

docker run -p 8787:8787 \
  -v $(pwd)/config.yaml:/data/config.yaml \
  -v fitguard-data:/data \
  -e OPENAI_API_KEY=sk-... \
  fitguard

The second volume matters: fitguard.db is written to /data, and without a named volume it is removed with the container, along with your spend history and everything the dashboard shows.

Choose a deployment path

Start locally, then choose one short guide. FitGuard's production decisions are simple: keep secrets out of Git, expose the platform's port, protect the dashboard, and decide where fitguard.db should live.

Running it on its own domain

If you already run services on subdomains (identity.example.com, sales.example.com), give FitGuard one too: ai.example.com. It's an independent service in the request path for all of them, and a subdomain keeps its routing, TLS, and access rules separate from any single app.

A complete Caddyfile, including automatic HTTPS:

ai.example.com {
    reverse_proxy localhost:8787
}

Or nginx, where the SSE dashboard feed needs buffering explicitly disabled:

server {
    server_name ai.example.com;

    location / {
        proxy_pass http://localhost:8787;
        proxy_http_version 1.1;
        proxy_set_header Host $host;

        # Required: the dashboard's live feed is SSE.
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Your applications then point at https://ai.example.com/v1, and the dashboard is at https://ai.example.com/dashboard.

Tell FitGuard about your proxy

Whenever you put a proxy in front, add its address to trusted_proxies. Skipping this doesn't break anything visibly, which is exactly why it's worth doing deliberately:

trusted_proxies:
  - 127.0.0.1
  - 10.0.0.0/8       # or your load balancer's range
Without itWith it
Every request appears to come from the proxy, so the dashboard's 5-attempts-per-minute login limit is shared by all users. One attacker locks out your whole team. The limit applies per real client address.
FitGuard can't tell the client used HTTPS, so the session cookie isn't marked Secure. The cookie is Secure, and browsers refuse to send it over plain HTTP.
Only set this when a proxy really is in front. These headers are trusted once listed, so on a directly-reachable deployment an attacker could set X-Forwarded-For themselves and claim a new address on every login attempt, defeating the rate limit. Left unset, FitGuard uses the real connection address and ignores the headers.
The dashboard is always on the same host and port as the proxy. It isn't a separate service and there's nothing extra to deploy. Wherever FitGuard is reachable, append /dashboard. Locally that's http://localhost:8787/dashboard; behind the proxy above it's https://ai.example.com/dashboard. Using Redis does not change this.

TLS

FitGuard serves plain HTTP and does not terminate TLS itself, since reverse proxies and load balancers already handle certificate management well. Put it behind Caddy, nginx, a cloud load balancer, or your platform's built-in TLS. Virtual keys travel in an Authorization header, so exposing the port over unencrypted HTTP on a public network leaks them.

One instance vs. many

With the default budget.backend: local, reservations live in one process's memory. A single FitGuard instance enforces budgets correctly, including under concurrent load.

Multiple instances behind a load balancer, still on local, each have their own view of spend. A request routed to instance B knows nothing about what instance A has reserved, so a user's real spend can reach roughly (instance count) × their limit. No error is raised when this happens; it shows up on the invoice.

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

Every instance then reserves and settles against the same Redis key, atomically, so the budget holds no matter where a request lands.

The dashboard caveat when scaling

Cost logging stays per-instance even with budget.backend: redis. fitguard.db is a local SQLite file, so each instance's dashboard only shows the requests it handled itself. Budget enforcement no longer depends on that data once Redis is the source of truth, but the dashboard does.

Your options: point every instance's data_dir at the same shared, concurrent-safe filesystem, or accept per-instance dashboards and aggregate externally via /dashboard/api/data.

Don't point multiple instances at the same fitguard.db on a filesystem that isn't safe for concurrent SQLite access. You'll get database is locked errors. See troubleshooting.

Production checklist

 Check
Real provider keys come from environment variables, not the committed config
TLS terminates at a reverse proxy or platform load balancer
A dashboard login is configured (fitguard reset-dashboard-password)
Every caller has its own virtual key with its own budget, not a shared one
budget.backend: redis if more than one instance runs
data_dir points at persistent storage, or you've accepted losing history
/healthz is wired to your uptime monitor
Startup warnings from fitguard run are clean
Esc