A production-grade, containerized deployment template for high-concurrency multiplayer virtual environments.
Zero-downtime · Resource-bounded · Self-healing · Infrastructure-as-Code
Arch-Orchestrator is an opinionated reference deployment for the back-end of
a real-time multiplayer service. It packages a 3-tier stack — persistence,
cache/messaging, and application core — into an immutable, declarative
artefact suitable for staging, production, or air-gapped environments.
The repository is intentionally narrow in scope: it does not ship game logic. It ships the plumbing — the orchestration topology, the hardened service configuration, the operational scripts, and the JVM scaffolding — that a Systems Architect or Platform Engineer needs to land a multiplayer runtime on Linux hosts with the rest of the team focused on gameplay code.
| Pillar | How it's enforced |
|---|---|
| Separation of Concerns | One directory per responsibility (/deploy, /configs, /core, /scripts). |
| Infrastructure as Code | Every host-affecting parameter lives in docker-compose.yml, *.cnf, or .env. No clickops. |
| Network isolation | Two bridge networks; the data plane is internal: true and unreachable from outside the host. |
| Resource ceilings | Every container has explicit CPU/RAM limits and reservations. |
| Observable startup | Health checks gate depends_on, so the core only boots once DB+cache report healthy. |
| Twelve-factor config | All secrets and tunables are environment variables sourced from deploy/.env. |
| Defensive scripting | Bash entry points use set -Eeuo pipefail, trap ERR, structured logging, retention policies. |
flowchart LR
subgraph host["Linux Host"]
direction LR
subgraph edge["archo_edge (bridge, public)"]
CORE["core<br/>JVM 21 + virtual threads<br/>:8080 (HTTP) :7777 (TCP/UDP)"]
end
subgraph data["archo_dataplane (bridge, internal: true)"]
DB[("MariaDB 11.4<br/>InnoDB · pool-of-threads")]
CACHE[("Redis 7.4<br/>AOF + RDB · Pub/Sub")]
end
CORE -- "JDBC<br/>HikariCP pool" --> DB
CORE -- "RESP3<br/>Lettuce client" --> CACHE
end
Clients[/"Game clients<br/>web / mobile / native"/] -- "TCP/UDP/HTTPS" --> CORE
Ops[/"Operator<br/>scripts/*.sh"/] -. "docker exec / stats" .-> host
classDef storage fill:#0b3d2e,stroke:#0f6,color:#fff;
classDef app fill:#103a5f,stroke:#5af,color:#fff;
class DB,CACHE storage;
class CORE app;
Why two networks? The application core is the only component on the public-facing bridge. MariaDB and Redis sit on a network with
internal: true— Docker will refuse to give it a default gateway, so even a compromised host-side process can't dial the DB without going through the orchestrator.
sequenceDiagram
autonumber
participant Op as Operator
participant DC as Docker Compose
participant DB as MariaDB
participant RC as Redis
participant CR as Core (JVM)
Op->>DC: docker compose up -d
DC->>DB: create container, mount /configs/mariadb/server.cnf
DC->>RC: create container, mount /configs/redis/redis.conf
par
DB-->>DC: healthcheck (mariadbupgrade & innodb_initialized)
and
RC-->>DC: healthcheck (redis-cli ping → PONG)
end
DC->>CR: start once DB.healthy && Redis.healthy
CR->>DB: HikariCP warm-up (min idle = DB_POOL_MIN)
CR->>RC: Lettuce connect
CR-->>DC: HTTP /health → 200 OK
DC-->>Op: stack READY
arch-orchestrator/
├── deploy/ # IaC entry point
│ ├── docker-compose.yml # Compose Spec — services, networks, limits, healthchecks
│ └── .env.example # Single source of truth for secrets & tunables
├── configs/ # Hardened service configuration (mounted read-only)
│ ├── mariadb/
│ │ └── server.cnf # InnoDB tuning, thread-pool, slow-query log, binlog
│ └── redis/
│ └── redis.conf # AOF+RDB, lazyfree, I/O threads, dangerous cmds disabled
├── core/ # JVM application scaffolding
│ ├── Dockerfile # Multi-stage: gradle:8 → temurin JRE (non-root, tini)
│ ├── build.gradle.kts # Kotlin DSL · Shadow plugin · JDK 21 toolchain
│ ├── settings.gradle.kts
│ └── src/main/java/com/archorchestrator/core/
│ ├── Application.java # Boot orchestration + graceful shutdown
│ ├── AppConfig.java # 12-factor env loader
│ ├── DataSourceFactory.java # HikariCP + MariaDB JDBC
│ ├── RedisClientFactory.java # Lettuce non-blocking client
│ └── HealthServer.java # /health /readyz /metrics on virtual threads
├── scripts/ # Operational automation
│ ├── _lib.sh # Shared logger / strict-mode / compose detection
│ ├── setup.sh # Bootstrap, secret generation, build/pull
│ ├── backup.sh # Hot-backup (mariadb-dump --single-transaction)
│ └── monitor.sh # Health, stats, readiness, logs (one-shot or watch)
├── Makefile # Convenience targets that wrap the scripts
└── README.md
- InnoDB tuned for OLTP: buffer pool sized to ~70 % of container RAM, four
buffer-pool instances,
innodb_io_capacity=2000,O_DIRECTflush method. - Thread-pool of threads instead of one-thread-per-connection — graceful behaviour at 5 000+ concurrent sessions.
- Row-based binary logging with minimal images, ready for replicas or PITR.
- Slow query log enabled at the 1 s threshold for production diagnostics.
- Strict SQL mode,
local_infile=OFF,secure_file_privquarantined. - Healthcheck uses upstream
healthcheck.shfrom the official image.
- Hybrid persistence: AOF (
everysec) + periodic RDB snapshots — durable enough for sessions, fast enough for cold restarts. - I/O threads enabled (
io-threads 4,io-threads-do-reads yes) for NIC- bound workloads. - Lazy-free everywhere — eviction, expire, server-del, user-del, user-flush.
- Hardening:
FLUSHALL,DEBUG,FLUSHDBrenamed to"";CONFIGandSHUTDOWNnamespaced behindARCHO_*prefixes. notify-keyspace-events "KEAxe"— out-of-the-box hooks for session expiry.- Eviction policy and memory ceiling injected via
.env, not the conf file.
- Java 21 with virtual threads powering the management HTTP server.
- HikariCP sized via env (
DB_POOL_MIN,DB_POOL_MAX) with leak detection. - Lettuce as the Redis client — non-blocking, virtual-thread friendly.
- Built-in
/health,/readyz,/metricsendpoints (Prometheus exposition). - JVM flags:
+UseZGC+ZGenerational+UseContainerSupportMaxRAMPercentage=75+ExitOnOutOfMemoryError. - Multi-stage image under 250 MB: Gradle builder → Temurin JRE, non-root
UID 10001,
tinias PID 1,HEALTHCHECKbaked into the image.
- Compose Spec v3.9 — works with Compose V2 and Swarm without modification.
- Per-service
deploy.resources.limits+reservations(CPU and memory). depends_onwithcondition: service_healthyfor both data services.restart: unless-stoppedeverywhere,stop_grace_period: 30son the core.- JSON-file logging driver with rotation (
max-size: 10m,max-file: 5, compressed) — bounded disk usage even with chatty applications. security_opt: no-new-privilegeson every container.ulimits.nofile = 65536— no surprise EMFILE under heavy fan-out.
- One-shot
setup.shgenerates secrets, materialises.env, pulls/builds. - Cron-friendly
backup.shwith retention rotation and partial-file safety. - Live
monitor.shdashboard (containers / health / stats / logs / probes). - Every script: strict mode,
trap ERR, RFC 3339 timestamps, ANSI-aware logger.
| Component | Minimum | Notes |
|---|---|---|
| Linux kernel | 5.10+ | cgroups v2 strongly recommended |
| Docker Engine | 24.0+ | needed for Compose Spec v3.9 + BuildKit cache mounts |
| Docker Compose | v2.20+ | Compose V2 plugin; docker-compose legacy works too |
| RAM | 8 GiB | default ceilings = 4 GiB (core) + 2 GiB (db) + 1 GiB (cache) |
| CPU | 4 vCPU | default ceilings sum to 7 vCPU; over-subscription is intentional |
| Disk | 20 GiB | InnoDB + binlogs + AOF + backups |
| Bash | 4.4+ | scripts use set -Eeuo pipefail and arrays |
git clone https://github.com/your-org/arch-orchestrator.git
cd arch-orchestrator
# 1. Bootstrap: verifies prerequisites, generates secrets, pulls + builds.
./scripts/setup.sh
# 2. Inspect deploy/.env and rotate any value you don't trust.
$EDITOR deploy/.env
# 3. Bring the stack up (detached).
./scripts/setup.sh --start
# or, equivalently:
make up
# 4. Watch the dashboard.
./scripts/monitor.sh --watch 5
# 5. Take a hot backup.
./scripts/backup.sh
# 6. Tear down (preserves volumes).
make downThe application core publishes:
| Port | Protocol | Purpose |
|---|---|---|
8080 |
HTTP | management surface (/health, /readyz, /metrics) |
7777 |
TCP | game server primary listener |
7777 |
UDP | low-latency real-time channel (movement, voice, telemetry) |
All values are defined in deploy/.env.example and
overridden in deploy/.env. The compose file fails fast (? operator) when a
required secret is missing, so unset variables are surfaced immediately
instead of silently defaulting to "".
| Variable | Default | Description |
|---|---|---|
APP_ENV |
production |
Logical environment label. |
APP_HTTP_PORT |
8080 |
Management surface port (host-side). |
APP_GAME_PORT |
7777 |
Game listener port (TCP+UDP). |
JVM_MAX_RAM_PCT |
75.0 |
Heap ceiling as % of container memory. |
JVM_INIT_RAM_PCT |
50.0 |
Initial heap as % of container memory. |
CORE_CPU_LIMIT |
4.0 |
Hard CPU ceiling (cgroup quota). |
CORE_MEM_LIMIT |
4g |
Hard RAM ceiling. |
| Variable | Default | Description |
|---|---|---|
MARIADB_ROOT_PASSWORD |
(required) | Root password — never bake into images. |
MARIADB_DATABASE |
archo |
Database created on first boot. |
MARIADB_USER |
archo |
Application user (granted on MARIADB_DATABASE). |
MARIADB_PASSWORD |
(required) | Application user password. |
MARIADB_CPU_LIMIT / _MEM_LIMIT |
2.0 / 2g |
cgroup ceilings. |
DB_POOL_MIN / DB_POOL_MAX |
10 / 50 |
HikariCP sizing on the core. |
| Variable | Default | Description |
|---|---|---|
REDIS_PASSWORD |
(required) | Used both server-side (--requirepass) and by clients. |
REDIS_MAXMEMORY |
768mb |
Hard ceiling enforced inside Redis (separate from cgroup limit). |
REDIS_EVICTION_POLICY |
allkeys-lru |
Use volatile-lru if you want only TTL-bearing keys evicted. |
| Variable | Default | Description |
|---|---|---|
BACKUP_DIRECTORY |
./backups |
Host-side dump destination. |
BACKUP_RETENTION_DAYS |
14 |
Older dumps are pruned by backup.sh. |
make up # docker compose up -d
make down # docker compose down (volumes preserved)
make restart # rolling restart of all services
make logs # tail -f for every container
make ps # docker compose ps
make build # rebuild local images, pulling base layersBackups are written to ./backups/archo-<db>-<UTC-timestamp>.sql.gz.
# Hot backup (no write lock thanks to --single-transaction)
./scripts/backup.sh
# Restore (drop+recreate the schema first if needed)
gunzip -c backups/archo-archo-20260101T020000Z.sql.gz \
| docker exec -i archo-mariadb mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"Schedule via cron (UTC, daily 02:30):
30 2 * * * cd /opt/arch-orchestrator && ./scripts/backup.sh >> /var/log/archo-backup.log 2>&1./scripts/monitor.sh # one-shot snapshot
./scripts/monitor.sh --watch 5 # refreshing dashboard, 5 s interval
./scripts/monitor.sh --service core --tail 50
curl -s http://127.0.0.1:8080/metricsThe compose topology is intentionally simple — each container is replaceable. For a true rolling update of the core service:
# Build the new image.
make build
# Replace the container with health-gated rollout.
docker compose --env-file deploy/.env -f deploy/docker-compose.yml \
up -d --no-deps --build --force-recreate coreWhen you outgrow a single host, the same compose file is deploy-compatible
with Docker Swarm (docker stack deploy -c docker-compose.yml archo)
because every limit lives under deploy.resources.
| Knob | Heuristic | Where |
|---|---|---|
innodb_buffer_pool_size |
~70 % of MARIADB_MEM_LIMIT |
configs/mariadb/server.cnf |
innodb_buffer_pool_instances |
1 per ~1 GiB of buffer pool | same |
innodb_io_capacity |
2 000 (SSD) — 20 000 (NVMe) | same |
thread_pool_size |
= number of vCPU | same |
max_connections |
1.5× DB_POOL_MAX × number of cores |
same |
wait_timeout |
600 s for sticky game sessions | same |
- Run
redis-cli --latency-history -h 127.0.0.1 -p 6379against a stable baseline; aim for p99 < 1 ms inside the container. - If your hot keyspace fits in RAM, set
REDIS_EVICTION_POLICY=noevictionand sizeREDIS_MAXMEMORYto ~80 % of the cgroup limit — surprises become OOMs instead of silent evictions. - For Pub/Sub fan-out > 10 K subscribers, raise
client-output-buffer-limit pubsuband watchINFO clients > total_blocking_clients.
- The compose file already enables ZGC + generational — best-in-class for multiplayer back-ends with low latency requirements (sub-ms pause goal).
- Profile with
-XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinalonce, capture the result into your runbook. - Virtual threads make the correct concurrency story per-request; avoid
pinning by never holding monitor locks across blocking I/O. Use
ReentrantLockif you need critical sections that span DB calls. - If you observe heap saturation, tune
JVM_MAX_RAM_PCTdown and let the container OOM rather than thrashing —+ExitOnOutOfMemoryErroris set, so the orchestrator restart policy will recover within seconds.
For load tests on Linux hosts (do not apply blindly to small VPS instances):
# Increase ephemeral port range and TIME_WAIT reuse
sysctl -w net.ipv4.ip_local_port_range="10240 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
# Larger conntrack table (game servers chew through entries)
sysctl -w net.netfilter.nf_conntrack_max=524288
# File descriptors
ulimit -n 1048576- No public DB. MariaDB and Redis are bound to an
internal: truebridge — they have no route to the internet. - Secrets in
.envonly.setup.shgenerates them withopenssl rand, writes the filechmod 600, and the compose file errors out if they're missing..envitself is.gitignored. - Non-root containers. The core image runs as UID 10001; MariaDB and Redis drop to their image-default unprivileged users.
security_opt: no-new-privilegeson every container — prevents setuid/setgid escalation paths inside the namespace.- Dangerous Redis commands renamed.
FLUSHALL,FLUSHDB,DEBUGare effectively disabled;CONFIGandSHUTDOWNare namespaced. - MariaDB hardening.
local_infile=OFF,secure_file_privquarantined, strict SQL mode. - TLS termination is intentionally out of scope — terminate at your edge
(Caddy / Nginx / Traefik / Cloud LB) and forward to
127.0.0.1:8080.
| Symptom | Likely cause | Fix |
|---|---|---|
core is unhealthy, others healthy |
DB or Redis password mismatch | Re-run ./scripts/setup.sh --force to regenerate .env. |
mariadb flaps with InnoDB: Cannot allocate memory |
MARIADB_MEM_LIMIT < innodb_buffer_pool_size |
Raise the cgroup limit or lower innodb_buffer_pool_size in configs/mariadb/server.cnf. |
Compose: "network has active endpoints" on down |
Containers from a previous run still attached | docker network rm archo_dataplane archo_edge after stopping. |
Healthcheck reports redis-cli: NOAUTH |
Stale REDIS_PASSWORD env in core but Redis is using a new value |
make restart so both pick up the same .env. |
| Backups grow without bound | BACKUP_RETENTION_DAYS set, but cron job not running |
Verify cron, or call backup.sh with --retention 7. |
core startup blocked > 60 s |
DB still applying mariadb-upgrade after a major version bump |
Patience — the start-period is 60 s; the orchestrator will eventually mark it healthy. |
- Optional
docker-compose.observability.ymloverlay (Prometheus + Grafana + Loki). - First-class MariaDB Galera profile for multi-writer HA.
- Redis Sentinel profile for cache failover.
- Helm chart equivalent for Kubernetes targets.
-
mariabackup(physical, point-in-time) profile alongside the existing logical dump.
- Bash: every script begins with
set -Eeuo pipefail, sources_lib.sh, useslog/log_warn/log_error, and exits with documented codes (64=usage,66=missing input,69=service unavailable,70=software,74=I/O,75=temp-fail). - YAML: 2-space indent, anchors for cross-cutting concerns (
x-logging,x-restart). - Java: JDK 21, virtual threads for I/O,
recordfor immutable config, no Lombok, no reflective magic — keep the audit trail short. - Compose: every service has
restart,logging,healthcheck,deploy.resources.limits,deploy.resources.reservations.
Released under the MIT License. Use it, fork it, ship it.