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.
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:
| File | Goes in git? | Where it lives instead |
|---|---|---|
Dockerfile | Yes | — |
config.example.yaml | Yes | The checked-in template, no real values |
config.yaml (or any name you give your real config) | No, never | Your own machine only, or the FITGUARD_CONFIG env var on platforms with no persistent disk |
fitguard.db | No, never | Runtime data, regenerated automatically and already gitignored |
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}
${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.
- Docker: local containers and a persistent named volume.
- Render, Heroku, and Railway: hosted container deployment, environment configuration, Redis, and platform storage.
- VPS: one production server on DigitalOcean, Microsoft Azure, or AWS with systemd and HTTPS.
- Persistence and operations: SQLite history, Redis state, backups, proxies, TLS, and scaling.
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 it | With 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. |
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.
/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.
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 |