# 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