Two starting points:
- Single instance with Docker Compose: one host, everything in this folder.
- Larger deployments: what to keep in mind when running several replicas, for example on Kubernetes.
All configuration is environment variables, documented in the root README.
docker-compose.yaml runs four services from the published image ghcr.io/wynnsource/wynnsource-server:
| Service | Role |
|---|---|
server |
The API, on port 8000 inside the container |
migration |
Runs alembic upgrade head once, then exits; server starts only after it succeeds |
db |
PostgreSQL 18, data in the db-data volume |
redis |
Redis (cache, rate limits, scheduler job locks), data in the redis-data volume |
cd deploy
cp .env.example .env
# Set at least POSTGRES_PASSWORD and WCS_ADMIN_TOKEN, and pin WCS_VERSION to a release.
docker compose up -d
docker compose ps # server should become healthy
curl http://127.0.0.1:8000/readyzTo run your own checkout instead of the published image, uncomment build: .. in docker-compose.yaml and use docker compose up -d --build.
The server is published on 127.0.0.1:8000 only. Expose it through a reverse proxy on the same host that:
- terminates TLS;
- sets
X-Real-IPto the client address. The server uses it for rate limiting and per-user IP records, and rejects token-authenticated requests that arrive without it (except from127.0.0.1); - forwards
HostandX-Forwarded-Proto.
Example for nginx:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}# set WCS_VERSION in .env to the new release
docker compose pull
docker compose up -d # migration runs first, then the server is recreatedReleases are tagged X.Y.Z; latest follows the newest release. dev-<sha> and dev-latest are pre-release builds from the dev branch.
The database is the only state worth keeping. Redis holds cache and locks that rebuild themselves.
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > wcs_db-$(date +%F).dump
# restore into an empty database:
docker compose exec -T db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists' < wcs_db-YYYY-MM-DD.dumpKeep copies off the host, and try a restore once before you need it.
docker compose down keeps the volumes; docker compose down -v deletes the database.
The server is stateless, so it scales horizontally. State lives in PostgreSQL; Redis is shared by all replicas.
flowchart LR
client([Clients]) --> proxy[TLS reverse proxy / ingress<br/>sets X-Real-IP]
proxy --> s1[server replica]
proxy --> s2[server replica]
s1 & s2 --> pg[(PostgreSQL)]
s1 & s2 --> redis[(Redis)]
mig[migration job<br/>alembic upgrade head] --> pg
s1 & s2 -. OTLP/HTTP .-> otel[OpenTelemetry collector]
- PostgreSQL and Redis are available.
- Run the migration once per release (
uv run --no-dev alembic upgrade headin the release image), as a one-shot job, not from every replica. - Roll the server replicas to the same image.
- Distinct identity per replica. Scheduled jobs take a lock in Redis held under
K8S_POD_NAME(defaults to the hostname). Telemetry needs a distinctservice.instance.idper replica, e.g.OTEL_RESOURCE_ATTRIBUTES=service.instance.id=<pod or host name>, so replicas never share a metric series. - Probes. Liveness on
GET /healthz, readiness onGET /readyz(port 8000). Give startup up to a minute before liveness checks begin. - Client IP. The proxy or ingress in front must set
X-Real-IP(see above). Uvicorn trusts forwarded headers only from127.0.0.1,::1and10.0.0.0/8. - Shutdown. The container runs uvicorn as PID 1 and handles
SIGTERM(finishes requests, flushes telemetry); give it a termination grace period of a few seconds. - Resources. About 100m CPU / 256 MiB per replica at rest is a reasonable request; limit memory at around 1 GiB.
- Keep
WCS_ADMIN_TOKEN, database credentials andWCS_SENTRY_DSNin a secret store and inject them as environment variables. Never commit them. - Non-secret settings (
LEVEL,LOG_JSON,MAX_IP_RECORDS,BETA_ALLOWED_VERSIONS) can live in plain configuration. Restart or roll the replicas when they change.
- Use a managed PostgreSQL or a PostgreSQL operator rather than a hand-run container.
- Enable point-in-time recovery (continuous WAL archiving plus base backups), store backups off the cluster, and test restores.
- Size
max_connectionsfor all replicas plus the migration job.
LOG_JSON=truewrites one JSON object per line, withtrace_id/span_id, for log pipelines.OTEL_EXPORTER_OTLP_ENDPOINTenables OpenTelemetry traces and metrics over OTLP/HTTP.WCS_SENTRY_DSNenables error reporting;WCS_SENTRY_PROFILE_SESSION_SAMPLE_RATEadds continuous profiling.
Pin a release tag and update it deliberately, or let an image-update tool follow the X.Y.Z tags (production) or dev-<sha> tags (staging). Avoid deploying latest, since a restart could then change the version.
A typical Kubernetes setup uses these objects. Write them in whatever tooling you use (plain manifests, Kustomize, Helm):
| Object | Purpose |
|---|---|
Deployment |
Server replicas with the probes and env above |
Service |
ClusterIP on port 8000 |
ConfigMap / Secret |
Settings and credentials, consumed with envFrom |
Job |
Migration, run before each rollout (e.g. as a sync or pre-upgrade hook) |
| PostgreSQL | Managed database or an operator-managed cluster |
| Redis | A small instance; persistence is optional |
Ingress (or equivalent) |
TLS and the X-Real-IP header |