Deploying ag-server¶
ag-server is one process: AGWP over gRPC and HTTP/JSON on a single port, plus /healthz, /readyz,
/metrics and /openapi.json. Everything lives under one data directory (a SQLite control database
with credentials, role bindings and the repository registry; per-repository data unless you configure
PostgreSQL and S3 — see postgres-and-s3.md). To try a server on your machine first, with
roles, credentials, a policy and an agent, follow Run a team server; to see it working in one command,
ag-server dev (Getting started) makes a throw-away evaluation server on the loopback interface. It is not
a deployment: plain HTTP, one administrator whose credential lives eight hours, a fixed demo policy.
Three ready-made ways to run it, all under deploy/:
| For | Files | |
|---|---|---|
| Binary | trying it, small teams | ag-server serve --config ag-server.toml (the release archives contain it; there is no ready-made systemd unit — write one around that command) |
| Docker Compose | one host with PostgreSQL and SeaweedFS (S3) | deploy/compose/ |
| Kubernetes | a cluster | deploy/kubernetes/ (kustomize) |
What has been verified¶
Be clear about what "ready-made" means here.
Checked in CI on every change (ci.yml: scripts/check_deploy.py --strict, cargo test -p agentgit-server --test deploy_configs) |
Not verified — treat as a starting point |
|---|---|
| Every YAML/JSON file parses | The alert thresholds are reasoned defaults, not tuned on real traffic |
kustomize build of every kustomization (base, ci, prometheus-operator, ha); kubeconform --strict against Kubernetes 1.30 schemas (and the Prometheus Operator CRDs) |
The prometheus-operator overlay has never been applied to a cluster (the Operator's CRDs are only schema-checked) |
promtool check rules / check config |
Behaviour under load; upgrades and failover of the cluster. The server's multi-replica behaviour (kill -9, rolling restart, no lost update) is proved by process tests (High availability); the ha overlay has not been applied to a cluster |
docker compose config with and without secrets (it refuses to start without them) |
A cluster other than kind: ingress, TLS termination, a real storage class, a CNI that enforces the NetworkPolicy |
Every shipped ag-server.toml loads and validates with this server |
Published images: the v0.2.0-rc.1 run pushed ghcr.io/paffin/agentgit-server:0.2.0-rc.1 (multi-arch, anonymously pullable; checked through the registry API, never started from there) but that tag produced no release; the default is now 0.2.0-rc.2, which resolves once that tag is pushed; until then build the image from deploy/docker/Dockerfile (the ci overlay shows how to use it in kind). The default tag is the workspace version, which is what release.yml pushes; check_deploy.py keeps the three defaults equal to it |
| Every metric used by alerts and the dashboard is one the server exports | The release workflow has only been rehearsed without publishing; the first tag is its first real publish (releasing.md) |
ag-server healthcheck, bearer-protected /metrics and trace export are covered by integration tests |
Run for real in the Containers workflow (every change that can alter an image or deploy/) |
|
|---|---|
deploy/docker/Dockerfile builds both targets; the images are under 100 MiB, run as uid 65532 with no shell and a health check, and report the commit they were built from; the server image embeds the Web UI (the log says so, / serves the app, its scripts are served pre-compressed) |
|
The server image runs with a read-only root and no capabilities and passes the end-to-end smoke (scripts/e2e_smoke.py: repository, roles, credentials, ag clone, snapshot, ag push, integrate, read back over HTTP, metrics), again after a restart |
|
| A rebuild on a fresh runner restores the dependency, UI and compile layers from the cache | |
docker compose up of deploy/compose (server, PostgreSQL, SeaweedFS as the S3 store): the same smoke, rows in the repository's PostgreSQL schema and objects in the bucket, a restart that loses nothing, and the observability profile (Prometheus scrapes with the token file, rules load, Grafana and the collector answer) |
|
deploy/kubernetes (base through the ci overlay) applied to a kind cluster (Kubernetes 1.33): restricted Pod Security, no restarts, the same smoke through a port-forward, a rollout restart that keeps the data |
If something here fails on your cluster, that is a bug in these files — please report it.
Secrets¶
| Variable | Purpose |
|---|---|
AG_BOOTSTRAP_TOKEN |
the first administrator (use 32 or more random characters; the server refuses to start with fewer than 16). Use it to create users and roles, then mint scoped credentials and stop using it |
AG_METRICS_TOKEN |
bearer token for /metrics. Set it: without one the server logs a warning when it listens on a non-loopback address, because anyone who can reach the port can read the metrics |
AG_PG_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN |
only with PostgreSQL / S3 storage |
OTEL_EXPORTER_OTLP_HEADERS |
only if the trace collector needs headers (authorization=Bearer …) |
None of them belongs in a config file, an image or Git. The configuration names the environment
variables; the values come from your secret manager, a Kubernetes Secret or Compose's .env.
TLS¶
The server refuses to listen on a non-loopback address without TLS unless allow_insecure_remote = true.
Choose one:
- Terminate TLS in front (ingress, load balancer, mesh, reverse proxy) and set
allow_insecure_remote = true— what the shipped container configs do. Keep the port off the public network (the Compose file publishes on127.0.0.1only; the Kubernetes NetworkPolicy admits only the namespace). Make sure the proxy speaks HTTP/2 to the backend, or gRPC clients will not work. - TLS in the server: set
tls_certandtls_keyunder[server]. gRPC and HTTPS then share the certificate.
Docker Compose¶
cd deploy/compose
cp .env.example .env # fill in every value (openssl rand -hex 32)
docker compose up -d --build
curl -fsS http://127.0.0.1:8080/readyz
This starts the server, PostgreSQL 16 and SeaweedFS (an S3-compatible store) and creates the bucket. Repositories created on this server keep metadata in their own PostgreSQL schema and objects under their own prefix of the bucket. Create the first user:
docker compose exec ag-server /usr/local/bin/ag-server admin --data-dir /data grant \
--subject user:alice --repo '*' --role admin
docker compose exec ag-server /usr/local/bin/ag-server admin --data-dir /data mint \
--subject user:alice --ttl 86400
(admin works on the data directory directly, also while the server is stopped.)
The image and tag are AG_IMAGE / AG_VERSION in .env (default ghcr.io/paffin/agentgit-server / the workspace version). The container health check calls ag-server healthcheck, whose URL is AG_HEALTHCHECK_URL (default http://127.0.0.1:8080): set it in the container environment when server.listen or TLS differ.
Add dashboards and tracing with docker compose --profile observability up -d after writing the metrics
token to secrets/metrics_token — see observability.md.
Kubernetes¶
kubectl apply -f deploy/kubernetes/base/namespace.yaml
kubectl -n agentgit create secret generic agentgit \
--from-literal=AG_BOOTSTRAP_TOKEN="$(openssl rand -hex 32)" \
--from-literal=AG_METRICS_TOKEN="$(openssl rand -hex 32)"
kubectl apply -k deploy/kubernetes/base # or overlays/prometheus-operator
kubectl -n agentgit rollout status deploy/ag-server
The image defaults to the workspace version (0.2.0-rc.2, the X.Y.Z tag release.yml pushes; :latest exists only for a stable release and moves). Override it with images: in your own kustomization (newName/newTag, for a private registry or another release), and expose the Service through
your ingress/gateway with TLS. The pod runs as uid 65532 with a read-only root filesystem, no capabilities,
the RuntimeDefault seccomp profile and no service-account token, under the restricted Pod Security
level.
The prometheus-operator overlay also adds an ingress rule to the NetworkPolicy for the namespace monitoring
(a patch in its kustomization.yaml): change the namespace there to where your Prometheus runs, and add a rule for
your ingress controller (the base file shows an example). Its ServiceMonitor sets jobLabel, so the series carry
job="agentgit", which is what alerts.yml selects; check_deploy.py verifies that.
Scaling¶
The default is exactly one replica. The control database is a SQLite file on the volume, and without
PostgreSQL/S3 repositories are files on the volume too, so two pods cannot share state (strategy: Recreate with a
ReadWriteOnce volume enforces this). Plan for a restart taking seconds (the startup probe allows about a minute)
and size the single pod for your peak: the server sheds load with 503 unavailable + Retry-After when
limits.max_concurrent_requests is reached, and clients retry.
Several replicas are supported when the control database is on PostgreSQL ([control] url_env) and the
repositories are on PostgreSQL + S3: no shared disk, rolling upgrades, a replica can be killed at any time. That
topology, its guarantees (each one a multi-process test), its failure modes, the move from a single server
(ag-server admin migrate-control) and its limits (PostgreSQL stays a single point of failure unless it is itself
highly available; rate limits are per replica) are in High availability. The Kubernetes
overlay is deploy/kubernetes/overlays/ha; the Compose stack stays single-host.
Backups¶
- Repositories on local storage or PostgreSQL/S3: see durability.md and postgres-and-s3.md.
- The control database (
/data/control.db) holds credentials' hashes, role bindings and the repository registry. Back up the volume with snapshots, or copy the file while the server is stopped. Losing it means re-creating users and roles; it does not lose any repository's history. With the control database on PostgreSQL,ag-server admin backup-control --out FILEwrites a logical dump (credential hashes included: mode 0600) that works for either backend, andrestore-controlreads it;pg_dumpof the control schema is the other way (High availability).
Stale agent runs¶
An agent that crashes leaves its run running, its task in_progress and its leases held. The server ends such
runs only when it is told to: an operator (delegator or admin) calls POST /v1/repos/{owner}/{name}/runs/reap
(the Go orchestrator does it for the runs of sessions it lost), or you let the server do it for every repository:
[agents]
run_stale_after_secs = 900 # a running run without a heartbeat for this long is abandoned
min_stale_after_secs = 30 # a reap request below this is refused (a typo must not end every live run)
reap_interval_secs = 0 # > 0: reap every repository this often, audited as `system:reaper`; 0 = on request only
ag-server admin reap-runs <owner/name> [--stale-after-secs N] [--dry-run] does the same offline. A reap releases
the leases of the ended runs, revokes the credentials bound to them and writes run.abandon / lease.reap audit
entries; the counter is agwp_agent_runs_reaped_total (observability). From a workstation,
ag agent reap --remote [NAME] [--timeout 10m] [--dry-run] calls the same route (needs agent.operate; the server
enforces its min_stale_after_secs). With reap_interval_secs > 0 on several replicas of one installation, only the
holder of the run-reaper lease (a row of the control database, renewed on every pass, free after two intervals if
its holder dies or at once on a clean stop) reaps; a reap that finds nothing stale changes nothing anyway. See the
agents guide.
Upgrading¶
On-disk formats are versioned; a build refuses to open data written by a newer version, and older data is
migrated forward. Take a backup, deploy the new image, watch /readyz. Roll back by redeploying the old
image only if you have not run a version that migrated the data; otherwise restore the backup.
The control database moved to schema 3 with run-bound credentials (ADR-0010): two
additive columns on credentials. A build that only knows schema 2 refuses the migrated database instead of reading a
run-bound credential as an ordinary one, so across replicas start a new build first and roll back from a backup.