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.
411 lines
12 KiB
YAML
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
|