docker-compose.yml
Rama main · réplica de lectura del repositorio original.
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: