services: # ─── next-app (Next.js 16 RSC) ─────────────────────────────────────── # Standalone Bun-built Next.js process. After the legacy retirement # nginx routes EVERY user-facing route here (SEO pages, auth, /library, # /player, /admin, and the catch-all). RSC server-side fetches go # straight to go-api:8080 via the Docker network (GO_API_INTERNAL_URL), # NOT through nginx. At the nginx layer /api/* → go-api and # /socket.io/ → ws-server. next-app: build: context: . dockerfile: next-app/Dockerfile # Build args read from the env file (deploy.sh + docker compose # both pull --env-file=.env.production). NEXT_PUBLIC_SENTRY_DSN # MUST land at build because Next inlines NEXT_PUBLIC_* into the # client bundle. Empty default keeps a build-without-env working. args: NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} # The token says who may upload; these two say where to. All three # have to reach the build or the source-map step cannot run. SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-} SENTRY_ORG: ${SENTRY_ORG:-} SENTRY_PROJECT: ${SENTRY_PROJECT:-} # Set by deploy.sh from `git rev-parse HEAD`; empty elsewhere. GIT_SHA: ${GIT_SHA:-} # Where pages point AniList images: our own copy of the originals # (nginx /img/anilist/, filled by go-api's image warm job) instead of # AniList's CDN. Inlined at build time like every NEXT_PUBLIC_*. The # `-` (not `:-`) is the off switch: an empty value in .env.production # survives and puts every image back on AniList at the next deploy. NEXT_PUBLIC_IMAGE_MIRROR_BASE: ${NEXT_PUBLIC_IMAGE_MIRROR_BASE-https://animegoclub.com/img/anilist/} # Named so next-image below can run this same build without building it # a second time. It is the name compose would give it anyway. image: animego-next-app:latest restart: unless-stopped env_file: .env.production environment: # RSC server-side fetch base (next-app server → go-api directly via # the Docker network, NOT through nginx). .env.production also sets # this; the default here keeps a bare `docker compose up` coherent # now that the legacy Express `app` service is retired. GO_API_INTERNAL_URL: ${GO_API_INTERNAL_URL:-http://go-api:8080} NODE_ENV: production NEXT_TELEMETRY_DISABLED: "1" PORT: "3000" HOSTNAME: "0.0.0.0" # Server-side timezone. Without this the container defaults to UTC # and any toLocaleTimeString / toLocaleDateString call SSR-renders # a different string than the client (browser local TZ) → # React error #418 hydration text mismatch. Asia/Shanghai matches # the primary user base (CN/HK/TW/海外华人). Browser-side TZ # outliers (US/EU users) will still mismatch — those callsites # need suppressHydrationWarning as a separate fix. TZ: Asia/Shanghai depends_on: - go-api networks: - animego healthcheck: # Use 127.0.0.1 not localhost: alpine resolves localhost to ::1 # first, but next 16 standalone server only binds the IPv4 # wildcard (HOSTNAME=0.0.0.0). v6 connect fails, container shows # unhealthy even though every external request works. test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/healthz"] interval: 15s timeout: 3s retries: 3 start_period: 30s # Headroom for SSR while caches are still cold after a deploy. mem_limit: 1g logging: driver: json-file options: max-size: "10m" max-file: "3" # ─── next-image (the image optimizer, on its own) ──────────────────── # The same build as next-app, but nginx sends it /_next/image and nothing # else. An image nobody has asked for yet costs a download and an AVIF # encode, and anyone can ask for any image the site shows; when a scraper # did exactly that, the encodes shared next-app's CPU and page renders timed # out behind them. Here they get a ceiling of their own: a flood of new # images makes images slow (past nginx's max_conns, some come back 503 and # are tried again on the next view), and pages keep the rest of the machine. # # No env_file: this container fetches and decodes images from outside and # needs none of the application's secrets to do it. next-image: image: animego-next-app:latest # Built by next-app's `build:` above, never pulled from a registry. pull_policy: never restart: unless-stopped environment: NODE_ENV: production NEXT_TELEMETRY_DISABLED: "1" PORT: "3000" HOSTNAME: "0.0.0.0" TZ: Asia/Shanghai # Server-side error reporting, the one runtime variable the server reads # at boot (sentry.server.config.ts). Empty disables it. SENTRY_DSN: ${SENTRY_DSN:-} networks: - animego healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/healthz"] interval: 15s timeout: 3s retries: 3 start_period: 30s cpus: 1.5 # Every request in flight holds its source image, and the ones sharp is # working on hold decoded frames too. nginx caps how many are in flight # (max_conns in default.conf, set so that bursts queue here rather than # fail). mem_limit: 1g volumes: # Optimized images survive container recreation (every deploy recreates # this container). Only images/: the rest of .next/cache is the fetch # data cache, which must not outlive the build that wrote it. - next-image-cache:/app/.next/cache/images logging: driver: json-file options: max-size: "10m" max-file: "3" # ─── ws-server (socket.io danmaku) ─────────────────────────────────── # Standalone Node 20 process speaking socket.io v4. Reads JWT_SECRET # shared with go-api so one login token works across surfaces. Writes # danmakus + episode_windows directly to Postgres. nginx routes # /socket.io/ here (the legacy Express `app` it used to share is retired). ws-server: build: context: ./ws-server dockerfile: Dockerfile restart: unless-stopped env_file: .env.production environment: PORT_WS: 3001 depends_on: postgres: condition: service_healthy networks: - animego healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] interval: 15s timeout: 3s retries: 3 start_period: 5s mem_limit: 128m logging: driver: json-file options: max-size: "10m" max-file: "3" # ─── P8.5: postgres (Postgres 16 + pg_cron) ─────────────────────────── # Custom image bundles pg_cron for danmaku TTL jobs and River queue # extension support. pg_cron is loaded via shared_preload_libraries # baked into the image's postgresql.conf.sample — no runtime flag # needed beyond the command below (which is belt-and-suspenders for # fresh volumes that haven't run initdb yet). # go-api + ws-server both connect via DATABASE_URL constructed in their # respective environment blocks; neither reads POSTGRES_PASSWORD directly. postgres: build: context: ./go-api/docker/postgres restart: unless-stopped environment: POSTGRES_DB: animego POSTGRES_USER: animego POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres-data:/var/lib/postgresql/data command: ["postgres", "-c", "shared_preload_libraries=pg_cron", "-c", "cron.database_name=animego"] networks: - animego healthcheck: test: ["CMD-SHELL", "pg_isready -U animego -d animego"] interval: 10s timeout: 3s retries: 5 start_period: 10s # Headroom above shared_buffers for per-connection work under load. mem_limit: 1g logging: driver: json-file options: max-size: "10m" max-file: "3" # ─── P9: schema migrate (one-shot) ──────────────────────────────────── # Applies go-api/migrations/*.sql (golang-migrate) to the postgres volume. # NOT in the default `up` set — guarded behind the `migrate` profile so it # only runs on an explicit `docker compose --profile migrate run --rm # migrate`. The cutover MUST run this before go-api starts: go-api's River # queue crash-loops on an empty DB (no river_job table). There is no other # schema-apply path — go-api/cmd/migrate does not exist, migrations are not # embedded in the server binary, and the VPS has no host golang-migrate. # Connects over the internal animego network (postgres:5432), so it dodges # the host-port IPv6/first-init race that p9-dry-run.sh works around. # Pinned to v4.19.1 to match the golang-migrate used locally. migrate: image: migrate/migrate:v4.19.1 profiles: ["migrate"] depends_on: postgres: condition: service_healthy volumes: - ./go-api/migrations:/migrations:ro command: [ "-path", "/migrations", "-database", "postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable", "up", ] networks: - animego restart: "no" # ─── P8.5: go-api (Go HTTP API) ─────────────────────────────────────── # Chi-based Go server. Waits for postgres healthcheck before starting # so pgxpool.Connect at boot never races a cold DB. # DATABASE_URL is constructed here from POSTGRES_PASSWORD so the # literal password never lands in .env.production (only the bare var does). # PORT_GO matches go-api/internal/config/config.go envInt("PORT_GO", 8080). go-api: build: context: ./go-api restart: unless-stopped # Docker's default stop grace is 10s, and main.go has always given # srv.Shutdown a 15s budget — so the graceful path was never actually # reachable: the kernel SIGKILLed the process five seconds into it. # Migration 0025's activity recorder made that visible rather than # merely wasteful, because it drains its in-memory buffer after # Shutdown returns and a SIGKILL there throws away the last minute of # counters. 30s covers 15s of shutdown, the recorder's 3s drain and the # job queue's 10s stop (queue.Shutdown: five to finish, five to cancel # what is still running), 28s in all. stop_grace_period: 30s env_file: .env.production environment: DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable PORT_GO: "8080" AVATAR_DIR: /data/avatars # Where the hant_backfill river worker finds the vendored zh-Hant # datasets. The Dockerfile bakes go-api/data/hant here rather than # go:embed-ing it, because cgroup-hk.json is CC BY-SA and embedding # would pull it into an AGPL package. The Go default is the # checkout-relative data/hant, which does not exist in the image, so # this line is what makes the sweep able to run at all in production. HANT_DATA_DIR: /usr/local/share/animego/hant # The image warm job asks nginx's internal warm location to store each # AniList original the database references. Port 8090 exists only on the # compose network (nginx publishes 80/443 and nothing else). As with the # mirror switch above, an empty value in .env.production turns the job # off. IMAGE_WARM_BASE_URL: ${IMAGE_WARM_BASE_URL-http://nginx:8090/warm/} # The variant warm job asks nginx's internal image location, overnight, # for every variant of those images the pages show, so nginx encodes and # keeps each before a reader needs it. The same internal-only port. An # empty value in .env.production turns the job off. IMAGE_VARIANT_WARM_BASE_URL: ${IMAGE_VARIANT_WARM_BASE_URL-http://nginx:8090/_next/image} volumes: # Member-pass photos live here (served at /api/avatars/*). A named # volume keeps them across deploys — back this up alongside postgres. - avatars:/data/avatars depends_on: postgres: condition: service_healthy networks: - animego healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] interval: 15s timeout: 3s retries: 3 start_period: 10s # Headroom for the in-process caches while they refill after a restart. mem_limit: 512m # More log than the other services keep: every API request writes a # line, and at 3 x 10m the night's job summaries (the image variant # warm passes, 01:00–07:00 Asia/Shanghai) had rotated away by the # morning. 5 x 50m holds about a day. logging: driver: json-file options: max-size: "50m" max-file: "5" # ─── Enrichment backfill (one-shot ops tool) ────────────────────────── # Re-validates every existing bgm binding against the authoritative id-map # + dandanplay, then fixes the legacy backlog (wrong bindings → reset for # re-enrich; missing CN → healed from dandanplay). NOT in the default `up` # set — guarded behind the `tools` profile. Reuses the go-api image (which # now bundles the bgmbackfill binary) + its env, overriding the entrypoint. # Runs as root so it can write report.json / backup-*.json into # ./backfill-out on the host — the operator reviews the report and KEEPS # the backup as the --apply rollback. # # Run on the VPS AFTER deploy + migrate, reviewing the report first: # mkdir -p backfill-out # docker compose --env-file .env.production --profile tools run --rm backfill --report # docker compose --env-file .env.production --profile tools run --rm backfill --apply # docker compose --env-file .env.production restart go-api # orphan scan re-enriches the reset rows # docker compose --env-file .env.production --profile tools run --rm backfill --heal backfill: build: context: ./go-api profiles: ["tools"] user: "0:0" working_dir: /out env_file: .env.production environment: DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable volumes: - ./backfill-out:/out depends_on: postgres: condition: service_healthy networks: - animego entrypoint: ["bgmbackfill"] restart: "no" logging: driver: json-file options: max-size: "10m" max-file: "3" # ─── zh-Hant title backfill (one-shot ops tool) ─────────────────────── # Fills anime_cache.title_hant / description_hant from the vendored # datasets in go-api/data/hant (baked into the image at # /usr/local/share/animego/hant). Same shape as `backfill` above: tools # profile, root so it can write the report and backup into ./backfill-out, # and the operator reads the report before applying anything. # # Nothing here touches the network, so unlike the enrichment workers there # is no rate-limit budget to blow by running it alongside a live go-api. # # mkdir -p backfill-out # docker compose --env-file .env.production --profile tools run --rm hantbackfill --report # docker compose --env-file .env.production --profile tools run --rm hantbackfill --apply # # later, to find rows whose source hash no longer matches its input: # docker compose --env-file .env.production --profile tools run --rm hantbackfill --restale hantbackfill: build: context: ./go-api profiles: ["tools"] user: "0:0" working_dir: /out env_file: .env.production environment: DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable volumes: - ./backfill-out:/out depends_on: postgres: condition: service_healthy networks: - animego entrypoint: ["hantbackfill", "--data", "/usr/local/share/animego/hant"] restart: "no" logging: driver: json-file options: max-size: "10m" max-file: "3" nginx: image: nginx:alpine restart: unless-stopped # 80 and 443 only. The warm server on 8090 stores whatever it is asked # for, so it must stay reachable from the compose network alone: never # add it here. ports: - "80:80" - "443:443" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/selfsigned.crt:/etc/nginx/ssl/selfsigned.crt:ro - ./nginx/selfsigned.key:/etc/nginx/ssl/selfsigned.key:ro # P8.1 Day-1 Fix: static maintenance page served as the `error_page` # target whenever next-app is unreachable (default.conf 502/503/504 # `@next_app_down`) or whenever a P6/P7/P9 surface is requested while # nginx is on default.legacy.conf rollback (Express has no handler). # Bind-mount, not COPY, so editing the file + `docker compose restart # nginx` is enough — no image rebuild. - ./nginx/maintenance.html:/usr/share/nginx/html/maintenance.html:ro # Both image caches in default.conf live on this volume: next_image # (encoded variants, so a recreated nginx does not send them all to # next-image again) and anilist_orig (the AniList originals, which # take hours to warm and must outlive every deploy). - nginx-cache:/var/cache/nginx # Rules that must not be public, kept only on the server (git ignores # the directory). default.conf includes what is there; an empty or # missing directory adds nothing. - ./nginx/private:/etc/nginx/private:ro depends_on: - next-app networks: - animego # Headroom for open connections and the image cache's working set. The # cache's key index is shared memory (keys_zone in default.conf) and # counts against this limit. mem_limit: 512m logging: driver: json-file options: max-size: "10m" max-file: "3" volumes: postgres-data: avatars: next-image-cache: nginx-cache: networks: animego: