Deployment
Two deploy files, same four images, same settings, same first-boot wizard. Pick the one that matches your machines.
- Docker Swarm - several machines, Core survives one of them rebooting, and labelling a host is what turns it into a Dylaris node.
- Single host - one machine, one file, no build step. For a single box this loses you nothing and is simpler.
Everything here assumes you have worked through Prerequisites: Docker installed, the swarm initialised and labelled if you are using one, a database chosen and the two secrets generated.
Redis runs ACL-only, and both stacks set that up for you. Valkey starts with
--aclfile and there is no open-Redis fallback, so the ACL file has to exist
before it does. The stacks below write it in the container's own entrypoint,
which means the only thing you fill in is the password.
Losing Redis outright is less serious than it looks. The only user that has to
be right is the default admin Core logs in as - that is what the entrypoint
writes. Core re-creates every scoped per-node user from its own records within a
minute of Redis coming back, with the same derived passwords it used before, and
the running services re-authenticate on their next command without a restart.
Docker Swarm
Your swarm is initialised and the hosts are labelled, so what is left is the stack file and what Docker does with it.
What deploy: is for
A stack is a compose file that Docker schedules for you. You do not say "start
this container on that machine", you say "I want two of these" and the manager
places them. The deploy: block is where that is written - Compose ignores it,
Swarm obeys it:
deploy:
replicas: 2 # how many copies
placement:
constraints: [node.labels.dylaris.core == true] # only on hosts you labelled
update_config:
order: start-first # new one healthy before the old one dies
parallelism: 1 # one at a time, never all at once
restart_policy:
condition: anyThat block is why an update is not an outage: start-first with parallelism: 1
replaces one replica at a time and only after the replacement answers.
mode: global instead of replicas means "one per host" - with a constraint
beside it, one per host that carries the label. The node service uses exactly
that, so what turns a machine into a Dylaris node is labelling it, not joining
the swarm.
Every service below is constrained to a label you set yourself, rather than
to node.role == manager or to nothing at all. Placement is an operational
decision and the roles differ: the database must never move, the node service
decides which machines run Minecraft servers, and Core and the panel hold
nothing and can go anywhere you point them. A service whose label exists on no
host stays pending forever, which is the loud version of getting it wrong -
Prerequisites has the four commands.
The Swarm stack file
Save this as docker-stack.yml on the manager. Every line you have to change is
marked # CHANGE ME; the rest is meant to stay as it is.
services:
core:
image: ghcr.io/bartis-dev/dylaris-platform-core:latest
environment:
API_PORT: "25500"
# CHANGE ME - the URL a browser opens the panel on.
FRONTEND_URL: "https://panel.example.com"
# CHANGE ME - both, from `openssl rand -hex 32`. Never the same value.
JWT_SECRET: "paste-the-first-random-hex-here"
CLUSTER_SECRET: "paste-the-second-random-hex-here"
DB_HOST: timescaledb
DB_PORT: "5432"
DB_USER: dylaris
DB_PASSWORD: "change-this-too" # CHANGE ME
DB_NAME: dylaris
DB_TYPE: "timescaledb"
DB_SSLMODE: "disable"
REDIS_ADDR: "redis:6379"
# CHANGE ME - must equal the password on the redis service below.
REDIS_PASSWORD: "change-this-redis-password"
# Must match on Core AND every node, or the mesh does not form.
GRPC_TLS_ENABLED: "true"
ports:
- "25500:25500" # REST API
- "25501:25501" # gRPC, node to Core
volumes:
# Library, ticket attachments and ticket backups. This is a per-host
# volume and there are two Cores, so they do NOT share it. Point "Core
# File Storage" in the panel at S3-compatible storage, or at a filesystem
# mounted at the same path on every host that may run Core. Core proves
# the path is genuinely shared before it accepts it, so a half-configured
# mount is refused rather than silently splitting your files in two.
# See "Core file storage" in Prerequisites.
- core_data:/app/dylaris_data
# Core's shutdown drains listeners, then releases its leader lease. Docker's
# default of 10s cuts that off before the first step finishes.
stop_grace_period: 90s
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:25500/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks: [dylaris_net]
deploy:
# Safe: Redis leader election keeps the singleton jobs on one replica
# while all of them serve the API.
replicas: 2
placement:
# YOUR choice which hosts run Core, expressed as a label you add
# yourself (see Prerequisites). Core is stateless apart from its file
# storage, so any host will do - but every host you label here must be
# able to reach that storage, or a Core scheduled onto it serves an
# empty library.
constraints: [node.labels.dylaris.core == true]
update_config:
order: start-first
parallelism: 1
restart_policy:
condition: any
node:
image: ghcr.io/bartis-dev/dylaris-platform-node:latest
environment:
# The name this node introduces itself with, templated per host so the
# value differs on every machine. It is NOT the node's identity: on first
# contact Core assigns a server-side id the node cannot choose, and logs
# "identity REPLACED ... the previous node row and its Redis ACL users are
# now orphaned". On a first boot there is no previous row and that line is
# noise; it means something only when a node that already had one lost its
# cached secret.
NODE_ID: "{{.Node.Hostname}}"
# CHANGE ME - the SAME value as Core's CLUSTER_SECRET.
CLUSTER_SECRET: "paste-the-second-random-hex-here"
# CHANGE ME - the SAME value as Core's JWT_SECRET. Core signs Beam tickets
# with it and the node checks them; a mismatch rejects every transfer.
BEAM_JWT_SECRET: "paste-the-first-random-hex-here"
REDIS_ADDR: "redis:6379"
CORE_GRPC_ADDR: "core:25501"
GRPC_TLS_ENABLED: "true"
# Host ports Minecraft servers bind, one each.
PORT_RANGE: "25600-25699"
volumes:
# The node drives the host Docker daemon to start server containers.
- /var/run/docker.sock:/var/run/docker.sock
- dylaris_data:/app/dylaris_data
networks: [dylaris_net]
# Nothing is published here on purpose. Each Minecraft container binds its
# own host port out of PORT_RANGE; claiming that range for the node makes
# every one of them fail with "port is already allocated".
deploy:
# One node task per LABELLED host. Global mode still means "one per
# host", the constraint just narrows which hosts count - so labelling a
# new machine is what turns it into a Dylaris node, not merely joining
# the swarm. That is the difference between deciding where servers run
# and finding out afterwards.
mode: global
placement:
constraints: [node.labels.dylaris.node == true]
restart_policy:
condition: any
panel:
image: ghcr.io/bartis-dev/dylaris-platform-panel:latest
environment:
# CHANGE ME - the address a BROWSER reaches Core on, including /api.
# Leave it EMPTY only behind a reverse proxy that routes /api to Core.
PANEL_API_URL: "https://api.example.com/api"
ports:
- "25510:25510"
networks: [dylaris_net]
deploy:
replicas: 2
placement:
# Your label again. The panel holds nothing at all - it is a web UI in
# front of Core's API - so it can share hosts with anything, including
# the ones running servers. Labelled rather than left free only so that
# WHERE it runs stays a decision you made.
constraints: [node.labels.dylaris.panel == true]
update_config:
order: start-first
parallelism: 1
restart_policy:
condition: any
timescaledb:
image: timescale/timescaledb:latest-pg16
environment:
POSTGRES_USER: dylaris
POSTGRES_PASSWORD: "change-this-too" # CHANGE ME - same as Core's DB_PASSWORD
POSTGRES_DB: dylaris
volumes:
- timescaledb_data:/var/lib/postgresql/data
networks: [dylaris_net]
deploy:
replicas: 1
placement:
# Its data is on one host's disk, so it must not be scheduled anywhere
# else - a reboot that moves it brings up an EMPTY database. Label
# exactly one host with dylaris.data.
constraints: [node.labels.dylaris.data == true]
restart_policy:
condition: any
redis:
# Valkey, a drop-in Redis fork. The service name stays "redis" so
# REDIS_ADDR=redis:6379 is right everywhere. Coordination bus only -
# Postgres is the source of truth, so nothing here needs persisting.
image: valkey/valkey:8-alpine
environment:
# CHANGE ME - the same value as Core's REDIS_PASSWORD. No spaces: ACL
# fields are space-delimited.
REDIS_PASSWORD: "change-this-redis-password"
# Valkey refuses to start against a missing --aclfile, so this writes the
# admin user first and then hands over to the image's own entrypoint, which
# is what drops the process from root to the valkey user. Overriding
# `command` instead would skip that drop.
entrypoint:
- sh
- -c
- |
printf 'user default on >%s ~* &* +@all\n' "$$REDIS_PASSWORD" > /data/users.acl
exec docker-entrypoint.sh valkey-server --save "" --appendonly no --aclfile /data/users.acl
networks: [dylaris_net]
deploy:
replicas: 1
placement:
# The SAME label as the database, deliberately. Valkey persists nothing,
# so it is not its own data that pins it - it is that the two singletons
# of this stack should live on one predictable host instead of drifting
# apart across reboots.
constraints: [node.labels.dylaris.data == true]
restart_policy:
condition: any
# ── Optional: MinIO, if you want S3 without an S3 provider ─────────────────
# Core's file storage wants S3-compatible storage or a filesystem every Core
# host can reach. Cloudflare R2 or any S3 provider is less to run; MinIO is
# here for a fleet that should not depend on anything outside it. Uncomment
# this, the minio_data volume, and point Settings -> Core File Storage at
# http://minio:9000 with path-style addressing on.
#
# It is a singleton on a disk, exactly like the database, so it carries the
# same label. Publishing 9001 is optional - it is the admin console, not the
# API Core talks to.
# minio:
# image: minio/minio:latest
# command: ["server", "/data", "--console-address", ":9001"]
# environment:
# MINIO_ROOT_USER: "dylaris" # CHANGE ME
# MINIO_ROOT_PASSWORD: "change-this-minio-password" # CHANGE ME - min 8 chars
# volumes:
# - minio_data:/data
# networks: [dylaris_net]
# deploy:
# replicas: 1
# placement:
# constraints: [node.labels.dylaris.data == true]
# restart_policy:
# condition: any
volumes:
timescaledb_data:
dylaris_data:
core_data:
# minio_data: # uncomment together with the minio service above
networks:
dylaris_net:
driver: overlay
attachable: trueAlready run a managed Postgres or Redis? Delete the timescaledb and redis
services, point DB_HOST / REDIS_ADDR at your endpoints and set
DB_SSLMODE: "require". One database on one host's disk is not high
availability, so a fleet that has to survive that host is better off external.
Deploy it
docker stack deploy -c docker-stack.yml dylaris
docker stack services dylaris
docker service logs -f dylaris_coreIf you parameterise the file with ${...} instead of the literal values above,
remember that Swarm resolves them at deploy time and does not read a .env for
you - set -a; . ./.env; set +a first, or use Docker secrets and the _FILE
form, which sidesteps it entirely.
Two things that bite
Minecraft containers are not swarm services. The node starts them on its host's Docker daemon directly, as siblings. Swarm does not know about them, will not move them, and will not restart them if the host dies - the node does that. A server's files live on the host it was created on.
A server belongs to a machine. Each node keeps its data in a volume on its
own host, so give the hosts that will run servers the roomy disks, and set
STORAGE_PATHS if you want that data somewhere specific.
Operations covers backups, which is what makes losing a host
survivable.
Then continue at Create the first admin.
Single host
One machine, plain Compose, no swarm involved. You paste one file, fill in the marked values and start it.
The single-host compose file
Save this as docker-compose.yml. Every line you have to change is marked
# CHANGE ME.
services:
core:
image: ghcr.io/bartis-dev/dylaris-platform-core:latest
restart: unless-stopped
depends_on:
timescaledb: { condition: service_healthy }
redis: { condition: service_started }
environment:
API_PORT: "25500"
# CHANGE ME - the URL a browser opens the panel on.
FRONTEND_URL: "http://localhost:25510"
# CHANGE ME - both, from `openssl rand -hex 32`. Never the same value.
JWT_SECRET: "paste-the-first-random-hex-here"
CLUSTER_SECRET: "paste-the-second-random-hex-here"
DB_HOST: timescaledb
DB_PORT: "5432"
DB_USER: dylaris
DB_PASSWORD: "change-this-too" # CHANGE ME
DB_NAME: dylaris
# timescaledb (hypertable + native retention) or postgres (plain table).
DB_TYPE: "timescaledb"
REDIS_ADDR: "redis:6379"
# CHANGE ME - must equal the password on the redis service below. Core
# logs in as the `default` admin and provisions the scoped users itself.
REDIS_PASSWORD: "change-this-redis-password"
# Must match on Core AND every node, or the mesh does not form.
GRPC_TLS_ENABLED: "true"
ports:
- "25500:25500" # REST API
networks: [dylaris_net]
volumes:
- core_data:/app/data
node:
image: ghcr.io/bartis-dev/dylaris-platform-node:latest
restart: unless-stopped
depends_on: [redis]
environment:
# The name this node introduces itself with. Core assigns the real
# identity on first contact and logs that it replaced this one - expected
# on a fresh install, see the Swarm stack above.
NODE_ID: "node-01"
# CHANGE ME - the SAME value as Core's CLUSTER_SECRET.
CLUSTER_SECRET: "paste-the-second-random-hex-here"
# CHANGE ME - the SAME value as Core's JWT_SECRET. Core signs Beam tickets
# with it and the node checks them; a mismatch rejects every transfer.
BEAM_JWT_SECRET: "paste-the-first-random-hex-here"
REDIS_ADDR: "redis:6379"
CORE_GRPC_ADDR: "core:25501"
GRPC_TLS_ENABLED: "true"
# Host ports Minecraft servers bind, one each.
PORT_RANGE: "25600-25699"
volumes:
# The node drives the host Docker daemon to start server containers.
- /var/run/docker.sock:/var/run/docker.sock
- dylaris_data:/app/dylaris_data
ports:
- "25520:25520" # SFTP
- "25523:25523" # Beam LAN fast path
# NOTE: do NOT publish 25600-25699 here. Each Minecraft container binds its
# own host port from that range; claiming the range for the node makes every
# one of them fail with "port is already allocated".
networks: [dylaris_net]
panel:
image: ghcr.io/bartis-dev/dylaris-platform-panel:latest
restart: unless-stopped
environment:
# CHANGE ME - the address a BROWSER reaches Core on, including /api.
# Use the machine's IP or hostname if you open the panel from anywhere
# other than this machine, e.g. http://192.168.1.10:25500/api
#
# Leave it EMPTY *only* behind a reverse proxy that routes /api to Core.
# Empty without a proxy means the panel asks itself for /api, gets a 404,
# never learns this is a fresh install, and shows you a login form for an
# account that does not exist yet.
PANEL_API_URL: "http://localhost:25500/api"
ports:
- "25510:25510"
networks: [dylaris_net]
timescaledb:
image: timescale/timescaledb:latest-pg16
restart: unless-stopped
environment:
POSTGRES_USER: dylaris
POSTGRES_PASSWORD: "change-this-too" # CHANGE ME - same as Core's DB_PASSWORD
POSTGRES_DB: dylaris
volumes:
- timescaledb_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks: [dylaris_net]
redis:
# Valkey, a drop-in Redis fork. The service name stays "redis" so
# REDIS_ADDR=redis:6379 is right everywhere. Coordination bus only - Postgres
# is the source of truth, so nothing here needs persisting.
image: valkey/valkey:8-alpine
restart: unless-stopped
environment:
# CHANGE ME - the same value as Core's REDIS_PASSWORD. No spaces: ACL
# fields are space-delimited.
REDIS_PASSWORD: "change-this-redis-password"
# Valkey refuses to start against a missing --aclfile, so this writes the
# admin user first and then hands over to the image's own entrypoint, which
# is what drops the process from root to the valkey user. Overriding
# `command` instead would skip that drop.
entrypoint:
- sh
- -c
- |
printf 'user default on >%s ~* &* +@all\n' "$$REDIS_PASSWORD" > /data/users.acl
exec docker-entrypoint.sh valkey-server --save "" --appendonly no --aclfile /data/users.acl
networks: [dylaris_net]
volumes:
timescaledb_data:
dylaris_data:
core_data:
networks:
dylaris_net:
driver: bridgeCheck it before you start it - this catches a typo in seconds instead of in a crash loop:
docker compose config >/dev/null && echo "the file is valid"Then start it:
docker compose up -d
docker compose psFive containers: core, node, panel, timescaledb, redis.
Create the first admin
Open the panel - http://localhost:25510 on a single host, or whatever address your proxy serves it on. On a fresh install the panel sends you to the setup wizard, which creates the first administrator. It only works while no admin exists, so it cannot be used to take over a running installation later.
Then create your first server - see Servers.
If you get a login form instead of the wizard, PANEL_API_URL is wrong. The
panel asks Core whether an admin exists; when that call fails it cannot tell, and
falls back to the login page. Open the browser console: a 404 on
/api/setup/status is this exact problem. Fix the value, deploy again, and
reload.
Before you put it on the internet
PANEL_API_URL is the browser's view, and it must end in /api. The browser
calls the API, not the container. If the panel is at https://panel.example.com
and Core at https://api.example.com, then PANEL_API_URL is
https://api.example.com/api - public URL, including the path. A value that
works inside Docker but not in a browser gives you a panel that loads and then
fails every request.
Set DB_SSLMODE=require if the database is on another host. Every other setting
is in Configuration.