Deployment
How Agent Shaker is actually put in front of users, and why it is shaped this way. For the authentication scheme itself see AUTHENTICATION.md.
Topology
┌──────────────────────────── TLS edge ────────────────────────────┐
│ Caddy (opt-in, `tls` profile) │
browser ─────────►│ / /api/* /ws → web:3000 + basic auth │
│ /mcp* /a2a/* /.well-known/* → mcp-server:8080 │
MCP / A2A client ►│ (API key, no basic auth) │
└───────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────┴─────────────────────┐
│ web (Nuxt SSR :3000) ── Nitro proxy ──┐ │
└─────────────────────────────────────────┼──────────┘
▼
mcp-server (Go, :8080, internal only)
│
▼
postgres:16
Two public entrypoints exist depending on profile:
| Profile | Public entrypoint | TLS | Machine clients (MCP/A2A) |
|---|---|---|---|
default (docker compose up -d) |
web on ${WEB_BIND}:${WEB_PORT} |
none — put your own proxy in front | unreachable |
tls (docker compose --profile tls up -d) |
Caddy on :443 / :80 |
automatic Let's Encrypt | reachable at /mcp, /a2a/v1/* |
The machine surfaces are only published under the tls profile. Without an
edge there is no proxy that can route them, and the Go service publishes no host
port. If you need MCP/A2A reachable and you are not using the tls profile,
either enable that profile or put your own reverse proxy in front and give
mcp-server a port to bind.
Quick start (TLS)
make deploy HOST=agent-shaker.example.com
That is the whole procedure. scripts/deploy.sh checks the ports, resolves
secrets (prompting, or generating with GENERATE=1), builds, starts, waits for
the certificate, and then verifies the live public origin — the agent card's
advertised URL, an MCP initialize, that /metrics is 404, that the human
surface rejects an unauthenticated request, and that the WebSocket route is
present rather than silently falling through to the SPA shell. It installs a
daily backup timer and prints the URLs.
Read scripts/deploy.sh before running it if you want to
know exactly what it touches. The steps it performs are, equivalently:
# 1. Required configuration
cp .env.example .env
$EDITOR .env # POSTGRES_PASSWORD, API_KEYS, PUBLIC_HOST, ACME_EMAIL
# 1b. PUBLIC_HOST must be the real domain, not empty and not localhost.
# Both compose and the Caddyfile default it to localhost, and Caddy issues
# no certificate for localhost: it serves plain HTTP, reports itself
# healthy, and puts your basic-auth password on the wire in cleartext.
# Nothing errors and nothing appears in any log.
grep '^PUBLIC_HOST=' .env
# 2. Export the basic-auth credential (see the warning below for why it is
# not in .env)
export BASIC_AUTH_USER=admin
export BASIC_AUTH_HASH="$(docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password')"
# 3. Bring it up — `make edge-up` is `docker compose --profile tls up -d --build`
make edge-up
# 4. Watch it converge
docker compose --profile tls ps
docker compose --profile tls logs -f edge
The first run starts with an empty database — migrations create the schema
and nothing else. Sample projects are not seeded; load them deliberately with
scripts/seed_demo_data.sql if you want them. See
MIGRATIONS.md.
Validate before you deploy:
make docker-config # both compose profiles
make caddy-validate # the Caddyfile parses
make caddy-fmt-check # the Caddyfile is in canonical form
CI runs all three on every push.
Generate the basic-auth hash once:
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'
See "Do not put the bcrypt hash in .env" below before you fill it in.
What the deploy script decides for you
Three choices are worth stating, because each one exists to avoid a failure that looks like something else.
BASIC_AUTH_HASH never touches .env. The hash is generated inside the
script, held in a shell variable, and exported for the docker compose call
only. Shell variables take precedence over .env, so this composes with a
populated .env without the interpolation corruption described below.
Generated passwords are hex. POSTGRES_PASSWORD is interpolated into
postgres://user:PASSWORD@postgres:5432/db. An alphabet containing /, @,
: or # produces a DSN that parses as a different host or a different
database, and that surfaces as a connection error rather than as a bad password.
openssl rand -hex 32 cannot produce any of them. API_KEYS travels in a
header and has no such constraint, but hex keeps it comma-safe for rotation.
The basic-auth password is prompted for, not read from .env. It is a
login, so it should be one the operator chose. It is typed without echo, and
cleared from memory by an exit trap. Only the hash survives, and only in the
process environment.
Generate everything non-interactively on a throwaway host with GENERATE=1;
generated secrets are printed exactly once, because there is no recovery path
for a lost API key.
Do not put the bcrypt hash in .env
BASIC_AUTH_USER and BASIC_AUTH_HASH are required for the edge to boot.
The edge service refuses to start without them, exiting 1 with an actionable
message. This is deliberate: a Caddyfile cannot branch on whether an environment
variable is set, so the only alternative is a placeholder credential, and a
guessable one is worse than a refusal to boot.
The check lives in the edge service's command rather than in compose's :?
syntax. Compose interpolates the whole file regardless of which profiles are
active, so :? would make these variables mandatory for a plain
docker compose up -d — forcing every developer without a TLS edge to invent a
bcrypt hash. Guarding at startup keeps the requirement scoped to the profile
that needs it.
FATAL: the tls profile requires BASIC_AUTH_USER and BASIC_AUTH_HASH.
The API key is not a login, so the edge is the only access
control in this topology. Generate a hash with:
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'
...
Why interpolation destroys it
This is the single easiest way to deploy a locked-out edge, so it is worth stating bluntly.
Docker Compose interpolates $VAR inside env-file values. A bcrypt hash is
$2a$14$<salt><digest>, and the $<salt> segment looks exactly like a
variable reference. (Hash below is elided — it is illustrative, not a real one):
$2a$14$SALTSEGMENT_THAT_LOOKS_LIKE_A_VAR/dgHtC5A3nqK5OWQ3mEXAMPLEaaKqDbMxP0MKI
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ treated as ${SALTSEGMENT_...} -> empty
becomes
$2a$14/dgHtC5A3nqK5OWQ3mEXAMPLEaaKqDbMxP0MKI
The result is still 60 characters in the wrong places and no longer parses as a bcrypt hash, so the edge starts, reports healthy, and rejects every password — a silent lockout with no error in any log. Export the value from the shell instead, where nothing re-interprets it:
export BASIC_AUTH_USER=admin
export BASIC_AUTH_HASH='$2a$14$...' # single quotes: literal, no expansion
docker compose --profile tls up -d
A shell variable takes precedence over .env, so this composes fine with a
populated .env file.
Things that do not work, all verified against this Compose version:
| Approach | Result |
|---|---|
BASIC_AUTH_HASH=$2a$14$... in .env |
salt eaten, every password rejected |
same via --env-file |
identical corruption |
same via service-level env_file: |
identical corruption |
doubling the $ ($$2a$$14$$...) |
still corrupted |
export ...='$2a$14$...' (single-quoted) |
works |
API_KEYS and POSTGRES_PASSWORD are unaffected — they contain no $. Only
bcrypt hashes hit this.
Why the edge splits traffic by caller
The alternative design is to teach the Nitro proxy to forward MCP and A2A
alongside /api, keeping Nuxt as the single upstream for everything. That does
not work:
-
Path collision.
/is a registered MCP endpoint on the Go service (the legacy JSON-RPC alias) and it is the SPA. Only one can own it, and the SPA has to. A Nitro-only design leaves the root endpoint permanently dead. -
SSE cannot survive the Nitro proxy. MCP streamable-HTTP and
POST /a2a/v1/message:streamrespond withtext/event-stream. The proxy inserver/routes/api/[...path].tsbuffers the upstream body into anArrayBufferand writes it withsend()— correct for JSON, fatal for a stream, because events only appear once the response closes. Streaming needs a genuine pass-through proxy, which is what Caddy'sreverse_proxyalready is.
So the split is by who is calling:
| Path | Upstream | Credential | For |
|---|---|---|---|
/, /api/*, /ws, /healthz |
web:3000 |
basic auth | browsers, the SPA |
/mcp, /mcp/*, /a2a/*, /.well-known/* |
mcp-server:8080 |
API key | agents, MCP hosts, scripts |
/metrics |
nothing — 404 | — | nobody; scrape it internally |
Consequence worth planning around: under the tls profile, /api/* is a
UI surface, not a machine one. A headless client with a valid API key still
gets a 401 there, because the edge requires basic auth on that path and a client
cannot send Basic and Bearer in the same Authorization header. Integrations
should use /mcp or /a2a/v1/*. If you genuinely need key-only REST access
from a script, put it on the machine branch in deploy/Caddyfile.
Why the machine branch has no basic auth
MCP clients authenticate with Authorization: Bearer <key>. HTTP basic auth
consumes the same header. A client physically cannot send both, so requiring
basic auth on the MCP path would make the surface unusable rather than more
secure.
Those paths are not left open: RequireAPIKey guards every one of them. The
property the topology actually guarantees is that every path requires a
credential, and no path requires two mutually exclusive ones.
Why /metrics is not routed
The catch-all sends it to Nuxt, which returns 404. Prometheus metrics at a
public origin are free reconnaissance, and nothing needs them from outside —
scrape mcp-server:8080/metrics on the internal network.
The edge's basic-auth credential never reaches the Go service
Behind this edge every browser request carries Authorization: Basic … for the
edge's own user gate. The Nitro proxy drops it rather than forwarding it: the
Go service expects an API key, and forwarding the Basic credential would both
break the API and write the perimeter password into the Go service's request
log. web/server/utils/upstreamAuth.ts is the single place that decision is
made, for both /api/* and /ws.
Health checks
| Endpoint | Checked by | Auth |
|---|---|---|
/healthz (web) |
web container healthcheck |
none |
http://127.0.0.1:2019/config/ (edge) |
edge container healthcheck |
none |
/healthz (Go) |
mcp-server container healthcheck |
none — probes are exempt |
/readyz (Go) |
external orchestrators | none — probes are exempt |
/healthz, /readyz, /metrics and /health are deliberately exempt from
API-key auth. An orchestrator has no credential, and a 401 there becomes a
crash-restart loop instead of a legible configuration error.
Rate limiting behind the edge
mcp-server sets TRUSTED_PROXY=true, and the reason is specific to this
topology.
Without it, every request reaches the Go service from the Caddy container's
own IP. The "per-IP" buckets collapse into one, so RATE_LIMIT_RPS=100
becomes a cap for the entire deployment rather than for each caller — a single
noisy client throttles everyone, including the UI.
The flag is safe here and only here: mcp-server publishes no host port, so
every request provably arrives over the internal network. For a bare binary on
a public host, leave it unset.
Why the rightmost X-Forwarded-For entry, not the leftmost
This is the part worth copying if you put another proxy in front. Reverse
proxies append to X-Forwarded-For. A client that sends its own header:
X-Forwarded-For: 1.2.3.4
comes out of Caddy as:
X-Forwarded-For: 1.2.3.4, <the address Caddy actually saw>
Reading the leftmost entry hands the caller their own bucket identity. Rotating a forged value per request then defeats rate limiting completely with one header — the opposite of the point of having it.
The service reads the rightmost entry, the one the trusted proxy itself appended, i.e. the peer it actually observed. The port is stripped as well, so varying the source port cannot mint buckets either.
Verified through the running edge: 600 concurrent requests carrying a rotating
forged X-Forwarded-For are throttled into the same bucket the honest requests
use, not into fresh ones.
What the access log shows
The same resolution is applied to logging, and deliberately shares one instance
across every route so the two agree. An access log whose remote field is the
proxy's address for every line is the one thing an access log cannot be.
Each line carries both:
remote— the client, as the rate limiter sees it. This is the field to group by.peer— the socket address. It is the proxy's in this topology, and keeping it means a misconfiguration is diagnosable: ifremoteever stops varying whilepeerdoes,X-Forwarded-Foris not arriving and the edge is not forwarding it.
Joining a 429 to the request that caused it is remote, and that join works
only because both subsystems resolve the same value.
Backups
A live deployment holds real projects, agents, tasks and contexts, and
docker compose down -v deletes all of it. These targets are the supported
way in and out of that state.
make db-backup # -> backups/agent-shaker-<ts>.dump + .sha256
make db-backups # list, newest first
make db-restore FILE=backups/<name>.dump
The dump is written to the host, never into the container. A file on the postgres volume dies with the volume it was meant to protect you from.
backups/ is git-ignored, and it must stay that way. A dump is the entire
dataset; -Fc is compression, not encryption.
db-restore replaces, it does not merge
It drops and recreates the public schema, then replays the dump. Everything created since the dump is gone. The target prints what it is about to do and sleeps three seconds so an accidental invocation can be interrupted.
Why the checksum is compared by hand
Each backup writes a .sha256 alongside the dump, and db-restore refuses to
proceed on a mismatch. The comparison is done by extracting the recorded hash
and hashing the file being restored — deliberately not sha256sum -c.
sha256sum -c verifies whatever filename the checksum file names, not the
file you passed in. Move or rename a dump and it either fails confusingly or,
worse, cheerfully verifies a different intact file while the corrupt one you
actually asked to restore sails through.
The case that matters is invisible: a corrupt dump of the same length as the
original, with a .sha256 naming some other intact file. A size check misses
it, sha256sum -c passes it, and the restore destroys the live database with
garbage. The direct comparison catches it.
Verified: same-length byte corruption and a truncated dump are both refused; an intact dump passes; and a full backup → delete → restore cycle brings the data back with the app healthy.
A2A discovery
The agent card is served at /.well-known/agent-card.json and is reachable
under the tls profile.
The URLs it advertises are derived per request from X-Forwarded-Proto /
X-Forwarded-Host (falling back to the request's own scheme and Host), so the
card describes the public origin rather than the Go container's internal one. A
hardcoded http://localhost:8080 would send every A2A client to a dead address.
Set BASE_URL to override this — the only case that needs it is an origin that
cannot be derived from a request, such as a separate API hostname.
The card also reports its real auth scheme. It used to hardcode none, which
was accurate when every surface was open and became a lie the moment API-key
auth landed: a conforming client would send no credential and collect a 401.
X-Forwarded-*is client-settable, so a caller can influence the advertised origin. The responses are generated per request, never cached, and already require an API key, so the worst case is a caller poisoning a card it is the sole reader of. SetBASE_URLto remove even that.
Environment variables
See .env.example for the full annotated list. The ones that
govern this topology:
| Variable | Default | Notes |
|---|---|---|
POSTGRES_PASSWORD |
— | required; compose refuses to start without it |
API_KEYS |
— | required; comma-separated, rotate by adding before removing |
AUTH_ENABLED |
true |
the Go service exits 2 if true with an empty key set |
NUXT_API_KEY |
${API_KEYS} |
the key Nitro injects; must be a member of API_KEYS |
BASIC_AUTH_USER / BASIC_AUTH_HASH |
— | required under the tls profile |
PUBLIC_HOST |
localhost |
the hostname Caddy issues a certificate for |
ACME_EMAIL |
admin@example.com |
Let's Encrypt contact |
HTTPS_PORT / HTTP_PORT |
443 / 80 |
|
WEB_BIND |
127.0.0.1 |
loopback-only, so Nuxt is not reachable around the edge |
WEB_PORT |
3000 |
|
TLS_TERMINATED |
false |
override only — HSTS is per-request. See below |
CORS_ALLOWED_ORIGINS |
http://localhost:3000 |
usually irrelevant; single origin |
CORS_ALLOW_CREDENTIALS |
false |
decoupled from AUTH_ENABLED on purpose |
HSTS needs no configuration
HSTS is decided per request, from r.TLS or X-Forwarded-Proto. Caddy sets
the latter, so enabling the tls profile gives you HSTS with no extra step.
This used to require TLS_TERMINATED=true set by hand alongside the profile.
Because compose cannot set an env var based on whether a profile is active,
that made it a second flag to remember — and omitting it was a silent
downgrade: the service started healthy, served traffic, and never sent the
header. That failure shape is now unreachable through configuration.
Set TLS_TERMINATED=true only if a fronting proxy does not set
X-Forwarded-Proto (a raw TCP passthrough, or a hand-rolled one). It does not
enable TLS; it only tells the API to assume the client-facing hop is encrypted.
A client that forges X-Forwarded-Proto: https on a cleartext request gains
nothing: per RFC 6797 a browser only honours HSTS received over a secure
transport, so the header is inert and cannot be used to downgrade anything.
Verifying a deployment
# Human surface — should be 200
curl -sk -u 'user:pass' https://your.host/healthz
curl -sk -u 'user:pass' https://your.host/api/projects
# Human surface without credentials — should be 401
curl -sk -o /dev/null -w '%{http_code}\n' https://your.host/api/projects
# Machine surface — should be 200 with the key, 401 without
curl -s -H "Authorization: Bearer $API_KEY" https://your.host/.well-known/agent-card.json
curl -s -o /dev/null -w '%{http_code}\n' https://your.host/.well-known/agent-card.json
# MCP initialize round-trip
curl -s -X POST https://your.host/mcp \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# A2A
curl -s -H "Authorization: Bearer $API_KEY" https://your.host/a2a/v1/tasks
Two checks that need a credential, not a curl -o /dev/null
/metrics and /ws sit on the edge's human branch, so the basic-auth gate
answers before the routing table is consulted. Unauthenticated they return
401 — the gate — regardless of whether the route exists. Probing them without
a credential therefore tells you nothing, and asserting 404 there fails on a
perfectly healthy deployment:
# 404 — not routed, so metrics are not public. Needs the credential.
curl -s -o /dev/null -w '%{http_code}\n' -u "$UI_USER:$UI_PASS" https://your.host/metrics
# 426 — the route exists and correctly refuses a non-upgrade GET.
# 200 instead would mean no route matched and the SPA shell was served,
# which breaks live updates without ever raising an error.
curl -s -o /dev/null -w '%{http_code}\n' -u "$UI_USER:$UI_PASS" https://your.host/ws
scripts/deploy.sh asserts exactly these values; they are measured, not assumed.
Confirm the agent card advertises your public origin, not the container's:
curl -s -H "Authorization: Bearer $API_KEY" https://your.host/.well-known/agent-card.json | jq .url
# "https://your.host/a2a/v1"
Releasing an image to Docker Hub
.github/workflows/publish-docker.yml publishes two images, and only from a
version tag:
| image | built from |
|---|---|
techbuzzz/agent-shaker-server |
./Dockerfile (Go) |
techbuzzz/agent-shaker-webui |
./web/Dockerfile (Nuxt) |
git tag v1.2.3
git push origin v1.2.3
That single tag push is the release. There is no separate publish step, and
merging to main publishes nothing — ci.yml already builds both images on
every push and pull request, so a second build on branch pushes would only
repeat work while adding a way to overwrite a published latest by accident.
v1.2.3 publishes three tags per image: 1.2.3, 1.2 and latest. A
prerelease (v1.2.3-rc1) publishes only 1.2.3-rc1 — 1.2 and latest
are left pointing at the last stable build, because those are the tags
consumers pull and an RC silently taking them over is discovered from someone
else's bug report.
Both images are built for linux/amd64 and linux/arm64.
Before the first release
Two repository settings, neither of which the workflow can create for itself:
- Secrets
DOCKERHUB_USERNAMEandDOCKERHUB_TOKEN— an access token with read/write on the namespace, not the account password. Same names the sibling projects use. - Environment
dockerhub-prod. Enable Required reviewers on it if you want a human to approve each push: thev*tag filter stops an ordinary merge from publishing, but it does nothing about a mistyped tag or about anyone who can push av*tag, and alatestthat moves is not undone by re-tagging.
Docker Hub repository overviews
Each repository's "Overview" tab on Docker Hub is rendered from a markdown file. The publish workflow updates both overviews automatically after a successful push, reading from:
| repository | overview source |
|---|---|
techbuzzz/agent-shaker-server |
docker/agent-shaker-server.README.md |
techbuzzz/agent-shaker-webui |
docker/agent-shaker-webui.README.md |
Edit those files to change what appears on Docker Hub. The update runs as the
last step of the publish workflow and uses the same DOCKERHUB_TOKEN as the
push. A failed overview update logs a warning but does not fail the
release — the images are already published and verified by then.
If a publish fails halfway
Re-running is safe: workflow_dispatch on the same workflow takes the tag as
input, and concurrency cancels an in-flight run for the same tag rather than
queueing behind it. The post-push steps re-verify both manifests for both
platforms, so a half-published tag is caught rather than discovered on pull.
Troubleshooting
/api/* returns 401 in the browser but the key is correct. The SPA holds no
key — the Nitro proxy injects NUXT_API_KEY for it. A 401 means the proxy had
no key to inject, which means the running web image predates the apiKey
runtimeConfig entry. Rebuild: docker compose build web && docker compose up -d web.
/.well-known/agent-card.json returns 404. You are not behind the tls
profile. See "Topology" above.
Caddy serves plain HTTP when you expected HTTPS. With PUBLIC_HOST=localhost
Caddy issues no certificate, but it still redirects HTTP→HTTPS. Test with
https://localhost/ and curl -k.
Everything is 502. web depends on mcp-server: healthy and the one-shot
migrate job must have completed. Check
docker compose logs migrate — migrations are forward-only, so a failed
up leaves the schema where it stopped.