Files
anchorage/deploy/docker-compose.yml
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

411 lines
12 KiB
YAML

# anchorage Production Stack — Docker Swarm
#
# Three anchorage instances, each paired 1:1 with its own Kubo sidecar,
# sharing a single Postgres cluster. NATS is embedded in each anchorage
# daemon (no separate NATS container needed). An nginx LB fronts HTTP
# and handles WebSocket upgrades for /v1/events.
#
# Setup:
# 1. Initialize swarm (if not already):
# docker swarm init
#
# 2. Label nodes for placement (HA setups with 3+ nodes):
# docker node update --label-add anchorage.db=true node-1
# docker node update --label-add anchorage.anchor=true node-1
# docker node update --label-add anchorage.anchor=true node-2
# docker node update --label-add anchorage.anchor=true node-3
#
# 3. Create secrets:
# openssl rand -base64 32 | docker secret create anchorage_postgres_password -
# openssl rand -base64 48 | docker secret create anchorage_jwt_key -
#
# 4. Deploy:
# docker stack deploy -c deploy/docker-compose.yml anchorage
#
# 5. Bootstrap sysadmin (one-shot against a live anchorage):
# docker run --rm --network anchorage_anchorage \
# git.anomalous.dev/alphacentri/anchorage:latest \
# admin promote-sysadmin admin@example.com
#
# 6. Check:
# docker stack services anchorage
# docker service logs anchorage_anchorage-1
# curl https://anchor.example.com/v1/health
#
# Environment variables (set in .env or shell before deploy):
# ANCHORAGE_IMAGE — container image (default: git.anomalous.dev/alphacentri/anchorage:latest)
# ANCHORAGE_DOMAIN — public hostname (required for OIDC callback)
# ANCHORAGE_AUTHENTIK_URL — Authentik issuer URL (required)
# POSTGRES_REPLICAS — Number of PG read replicas (default: 0)
# ANCHORAGE_REPLICAS — Number of anchorage+Kubo pairs (default: 3)
x-anchorage-env: &anchorage-env
ANCHORAGE_POSTGRES_DSN: "postgres://anchorage@postgres:5432/anchorage?sslmode=disable&password_file=/run/secrets/postgres_password"
ANCHORAGE_POSTGRES_AUTOMIGRATE: "false" # migrate runs as a one-shot service
ANCHORAGE_POSTGRES_MAXCONNS: "20"
ANCHORAGE_AUTH_AUTHENTIK_ISSUER: ${ANCHORAGE_AUTHENTIK_URL:?Set ANCHORAGE_AUTHENTIK_URL}
ANCHORAGE_AUTH_AUTHENTIK_CLIENTID: "anchorage-web"
ANCHORAGE_AUTH_AUTHENTIK_AUDIENCE: "anchorage"
ANCHORAGE_AUTH_APITOKEN_SIGNINGKEYPATH: "/run/secrets/jwt_key"
ANCHORAGE_AUTH_APITOKEN_DEFAULTTTL: "24h"
ANCHORAGE_AUTH_APITOKEN_MAXTTL: "720h"
ANCHORAGE_LOGGING_LEVEL: "info"
ANCHORAGE_LOGGING_FORMAT: "json"
ANCHORAGE_LOGGING_FILE: "" # stderr only in containers
x-anchorage-deploy: &anchorage-deploy
replicas: 1
placement:
constraints:
- node.labels.anchorage.anchor == true
restart_policy:
condition: any
delay: 5s
max_attempts: 0
resources:
limits:
cpus: "2"
memory: 2G
reservations:
cpus: "0.5"
memory: 512M
x-healthcheck-anchorage: &anchorage-healthcheck
# Liveness only — hits /v1/health via busybox wget (Alpine ships it).
# The drain-aware /v1/ready endpoint is probed separately by the LB.
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8080/v1/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
services:
# ===========================================================================
# Postgres — source of truth for pins, placements, orgs, tokens, audit
# ===========================================================================
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: anchorage
POSTGRES_USER: anchorage
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
- postgres_data:/var/lib/postgresql/data
secrets:
- postgres_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U anchorage -d anchorage"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
command: >
postgres
-c shared_buffers=1GB
-c effective_cache_size=3GB
-c work_mem=32MB
-c maintenance_work_mem=256MB
-c max_connections=200
-c wal_level=replica
-c max_wal_senders=5
-c max_replication_slots=5
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c log_min_duration_statement=500
-c log_checkpoints=on
deploy:
replicas: 1
placement:
constraints:
- node.labels.anchorage.db == true
restart_policy:
condition: any
delay: 5s
max_attempts: 0
resources:
limits:
cpus: "2"
memory: 4G
reservations:
cpus: "1"
memory: 2G
networks:
- anchorage
# ===========================================================================
# Postgres read replica(s) — opt-in via POSTGRES_REPLICAS
# ===========================================================================
postgres-replica:
image: postgres:17-alpine
environment:
PGUSER: anchorage
PGPASSWORD_FILE: /run/secrets/postgres_password
volumes:
- postgres_replica_data:/var/lib/postgresql/data
secrets:
- postgres_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U anchorage"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
entrypoint: |
bash -c '
export PGPASSWORD=$$(cat /run/secrets/postgres_password)
if [ ! -s /var/lib/postgresql/data/PG_VERSION ]; then
until pg_basebackup -h postgres -U anchorage -D /var/lib/postgresql/data -Fp -Xs -P -R; do
echo "Waiting for primary..." && sleep 2
done
fi
exec postgres -c hot_standby=on
'
deploy:
replicas: ${POSTGRES_REPLICAS:-0}
placement:
constraints:
- node.labels.anchorage.db == true
preferences:
- spread: node.id
restart_policy:
condition: any
delay: 5s
max_attempts: 0
networks:
- anchorage
# ===========================================================================
# anchorage-migrate — one-shot: applies schema, then exits
# ===========================================================================
anchorage-migrate:
image: ${ANCHORAGE_IMAGE:-git.anomalous.dev/alphacentri/anchorage:latest}
environment:
ANCHORAGE_POSTGRES_DSN: "postgres://anchorage@postgres:5432/anchorage?sslmode=disable&password_file=/run/secrets/postgres_password"
secrets:
- postgres_password
command: ["migrate", "up"]
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 10
window: 120s
# Migrate job should not be restarted after it exits 0; swarm's
# only way to model this is a high max_attempts + window so a
# completed job exits and swarm stops re-launching it.
networks:
- anchorage
# ===========================================================================
# Kubo daemons — one per anchorage, paired 1:1
# ===========================================================================
kubo-1: &kubo-base
image: ipfs/kubo:latest
environment:
IPFS_PROFILE: "server"
volumes:
- kubo_1_data:/data/ipfs
ports:
- target: 4001
published: 4001
mode: host
protocol: tcp
deploy:
replicas: 1
placement:
constraints:
- node.hostname == {{ index .Node.Labels "anchorage.anchor-id" }}
- node.labels.anchorage.anchor-id == anchor-1
restart_policy:
condition: any
delay: 5s
networks:
- anchorage
kubo-2:
<<: *kubo-base
volumes:
- kubo_2_data:/data/ipfs
ports:
- target: 4001
published: 4011
mode: host
protocol: tcp
deploy:
replicas: 1
placement:
constraints:
- node.labels.anchorage.anchor-id == anchor-2
restart_policy:
condition: any
delay: 5s
kubo-3:
<<: *kubo-base
volumes:
- kubo_3_data:/data/ipfs
ports:
- target: 4001
published: 4021
mode: host
protocol: tcp
deploy:
replicas: 1
placement:
constraints:
- node.labels.anchorage.anchor-id == anchor-3
restart_policy:
condition: any
delay: 5s
# ===========================================================================
# anchorage daemons — paired with kubos above
# ===========================================================================
anchorage-1: &anchorage-base
image: ${ANCHORAGE_IMAGE:-git.anomalous.dev/alphacentri/anchorage:latest}
environment:
<<: *anchorage-env
ANCHORAGE_NODE_ID: nod_anchor_1
ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage
ANCHORAGE_IPFS_RPC: http://kubo-1:5001
ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats
ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-2:6222,nats://anchorage-3:6222"
ANCHORAGE_CLUSTER_MINREPLICAS: "2"
ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4001/p2p/PLACEHOLDER"
volumes:
- anchorage_1_data:/var/lib/anchorage
secrets:
- postgres_password
- source: jwt_key
target: jwt_key
mode: 0400
depends_on:
- postgres
- kubo-1
- anchorage-migrate
healthcheck: *anchorage-healthcheck
deploy:
<<: *anchorage-deploy
placement:
constraints:
- node.labels.anchorage.anchor-id == anchor-1
networks:
- anchorage
anchorage-2:
<<: *anchorage-base
environment:
<<: *anchorage-env
ANCHORAGE_NODE_ID: nod_anchor_2
ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage
ANCHORAGE_IPFS_RPC: http://kubo-2:5001
ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats
ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-1:6222,nats://anchorage-3:6222"
ANCHORAGE_CLUSTER_MINREPLICAS: "2"
ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4011/p2p/PLACEHOLDER"
volumes:
- anchorage_2_data:/var/lib/anchorage
depends_on:
- postgres
- kubo-2
- anchorage-migrate
deploy:
<<: *anchorage-deploy
placement:
constraints:
- node.labels.anchorage.anchor-id == anchor-2
anchorage-3:
<<: *anchorage-base
environment:
<<: *anchorage-env
ANCHORAGE_NODE_ID: nod_anchor_3
ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage
ANCHORAGE_IPFS_RPC: http://kubo-3:5001
ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats
ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-1:6222,nats://anchorage-2:6222"
ANCHORAGE_CLUSTER_MINREPLICAS: "2"
ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4021/p2p/PLACEHOLDER"
volumes:
- anchorage_3_data:/var/lib/anchorage
depends_on:
- postgres
- kubo-3
- anchorage-migrate
deploy:
<<: *anchorage-deploy
placement:
constraints:
- node.labels.anchorage.anchor-id == anchor-3
# ===========================================================================
# LB — nginx fronts HTTP and upgrades /v1/events to WebSocket
# ===========================================================================
lb:
image: nginx:1.27-alpine
depends_on:
- anchorage-1
- anchorage-2
- anchorage-3
ports:
- target: 8080
published: 8080
mode: ingress
configs:
- source: nginx_conf
target: /etc/nginx/conf.d/default.conf
deploy:
replicas: 1
restart_policy:
condition: any
delay: 5s
resources:
limits:
cpus: "0.5"
memory: 128M
reservations:
cpus: "0.1"
memory: 32M
networks:
- anchorage
# =============================================================================
secrets:
postgres_password:
external: true
name: anchorage_postgres_password
jwt_key:
external: true
name: anchorage_jwt_key
configs:
nginx_conf:
file: ./nginx.conf
volumes:
postgres_data:
driver: local
postgres_replica_data:
driver: local
kubo_1_data:
driver: local
kubo_2_data:
driver: local
kubo_3_data:
driver: local
anchorage_1_data:
driver: local
anchorage_2_data:
driver: local
anchorage_3_data:
driver: local
networks:
anchorage:
driver: overlay
attachable: true