Skip to content
Documentation

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:

yaml
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: any

That 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.

yaml
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: true

Already 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

shell
docker stack deploy -c docker-stack.yml dylaris
docker stack services dylaris
docker service logs -f dylaris_core

If 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.

yaml
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: bridge

Check it before you start it - this catches a typo in seconds instead of in a crash loop:

shell
docker compose config >/dev/null && echo "the file is valid"

Then start it:

shell
docker compose up -d
docker compose ps

Five 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.