Single VM with Docker Compose
This is the simplest production-shape deployment of Orkestra: one VM (Ubuntu 24.04 or Debian 12), Docker Compose, the docker-compose.prod.yml stack, and a systemd unit that keeps the stack alive across reboots. No Kubernetes, no load balancer, no remote DB — everything on one host.
It's the right shape for:
- Solo operators self-hosting Orkestra for their own use
- Internal-tools deployments where a single VM is enough
- Pre-production environments before a multi-replica K8s deployment
For TLS termination and the per-audience host split (console.* + api.*), add the Caddy reverse-proxy from the next guide.
Sizing
| Resource | Minimum | Recommended (small team, ~50 users) |
|---|---|---|
| vCPU | 2 | 4 |
| RAM | 4 GB | 8 GB |
| Disk | 40 GB SSD | 100 GB SSD |
| Network | 100 Mbps | 1 Gbps |
The base (8 core modules) fits comfortably in the minimum sizing. A fork that adds heavy verticals (AI, graph, document rendering, etc.) should add headroom per workload — budget 4 GB per memory-hungry addon and account for any addon-managed infra containers it runs.
Step 1 — Prepare the VM
# As root or via sudo
apt-get update && apt-get install -y \
ca-certificates curl gnupg lsb-release git \
unattended-upgrades
Install Docker per the official guide — apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin. Add your deploy user to the docker group and re-login.
Configure unattended security upgrades:
dpkg-reconfigure unattended-upgrades # Yes
Step 2 — Clone and init
As your deploy user (NOT root):
cd /opt
sudo mkdir orkestra && sudo chown $USER:$USER orkestra
cd orkestra
git clone https://github.com/<your-org>/orkestra.git .
make init
make init scaffolds docker/.env (with random secrets) and generates RS256 JWT keys. There is no shared Docker network to create — each stack gets its own ${APP_NAME}-${ENV}_default. See I just forked Orkestra for what each step does.
Edit docker/.env:
- Set
ENV=production. - Set
BACKEND_URL,FRONTEND_URL,OPERATOR_FRONTEND_URL,CLIENT_FRONTEND_URL,CONSOLE_HOST,CLIENT_API_HOSTto your real hostnames. - Set
COOKIE_SECURE=trueandCOOKIE_SAME_SITE=strict. - Set
ALLOW_LOCALHOST_REDIRECTS=false. - Set
OPERATOR_COOKIE_DOMAINandCLIENT_COOKIE_DOMAINto non-overlapping hosts — see Cookie hardening. - Decide where the stack listens.
HOST_BIND_ADDRESSis the address the backend and console ports bind to; production defaults to127.0.0.1, which is right when the reverse proxy runs on this VM. A proxy on another host needs the private IPv4 it reaches (e.g.HOST_BIND_ADDRESS=10.0.0.5) plus the firewall rule in Step 4. LeaveINFRA_BIND_ADDRESS=127.0.0.1— nothing off-host talks to Mongo, Redis or RustFS.FRONTEND_PORT(default8080) is the console's host port. - Set
TRUSTED_PROXY_CIDRS(orTRUSTED_PROXY_COUNT) to describe the reverse proxy in front of the backend. Leave both unset and Orkestra ignoresX-Forwarded-Forentirely, attributing every request to the proxy — which collapses all callers into one login rate-limit bucket and makes the operator IP allowlist and geo-block match the proxy instead of the client. With Caddy or nginx on the same VM,TRUSTED_PROXY_COUNT=1is correct. - (Optional) Set
LOG_LEVEL=infoandPRETTY_LOGS=falsefor JSON logs that ship to Loki / Datadog / Honeycomb cleanly.
Step 3 — Start the stack
cd /opt/orkestra
./orkestra.sh deploy --yes --rebuild
deploy refuses a dirty worktree or a HEAD that is not origin/main, builds the backend and console images, starts infra → backend → console, and gates on scripts/health-check.sh — the same checks you would otherwise run by hand. The equivalent manual sequence, useful when debugging the compose files themselves:
cd /opt/orkestra/docker
export COMPOSE_PROJECT_NAME=orkestra-production # ${APP_NAME}-${ENV}, what orkestra.sh uses
docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml --env-file .env up -d --build
Always set COMPOSE_PROJECT_NAME to what orkestra.sh derives (${APP_NAME}-${ENV}): volume names are prefixed with the project, so a bare docker compose here would create a second, empty docker_mongodb-data next to the real one.
Verify:
./orkestra.sh status
curl -s http://127.0.0.1:3000/health # or http://$HOST_BIND_ADDRESS:3000/health
Step 4 — Network exposure and firewall
A port Docker publishes does not go through the host's INPUT chain — Docker inserts its own forwarding rules ahead of it. A ufw/nftables policy of "drop everything but SSH" therefore says nothing about what the stack exposes; the bind addresses in docker/.env do.
The production compose files default to closed:
| Port | Binds to | Default | Who needs it |
|---|---|---|---|
BACKEND_PORT (3000), FRONTEND_PORT (8080) | HOST_BIND_ADDRESS | 127.0.0.1 | the reverse proxy only |
RUSTFS_API_PORT (9100) | HOST_BIND_ADDRESS | 127.0.0.1 | the reverse proxy, only if STORAGE_PUBLIC_ENDPOINT routes browser uploads through it |
MONGO_PORT, REDIS_PORT, RUSTFS_CONSOLE_PORT | INFRA_BIND_ADDRESS | 127.0.0.1 | nobody off-host — containers use service names, backup.sh uses docker exec |
Proxy on this VM (the Caddy guide): nothing to do — Caddy reaches 127.0.0.1:3000 / :8080 and no port is visible on the network.
Proxy on another host: set HOST_BIND_ADDRESS to the private IPv4 the proxy reaches, then restrict those two ports to the proxy's address in Docker's DOCKER-USER chain — the one hook Docker evaluates before its own accept rules:
# 203.0.113.10 = the proxy; eth0 = the interface the bind address lives on
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m multiport --dports 3000,8080,9100 \
! -s 203.0.113.10 -j DROP
DOCKER-USER is not persisted by Docker: put the rule in a small oneshot unit ordered After=docker.service (or your distro's iptables-persistent) so it survives a reboot. Check the daemon's firewall backend first — docker info | grep -i firewall — and use nft equivalents on a host that runs Docker with the nftables backend. Verify from a machine that is not the proxy: curl -m 3 http://<HOST_BIND_ADDRESS>:3000/health must time out.
./scripts/env-validate.sh warns when a production .env leaves either bind address at 0.0.0.0.
Step 5 — systemd unit for auto-restart on reboot
The repo ships the unit at scripts/systemd/orkestra.service. Its WorkingDirectory, User and Group are placeholders — change them to your checkout path and deploy user before installing:
[Unit]
Description=Orkestra production stack (infra + backend + frontend-admin)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=deploy
Group=deploy
SupplementaryGroups=docker
WorkingDirectory=/opt/orkestra/docker
Environment=COMPOSE_PROJECT_NAME=orkestra-production
ExecStart=/usr/bin/docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml --env-file .env up -d
ExecStop=/usr/bin/docker compose -f docker-compose.infra.yml -f docker-compose.prod.yml --env-file .env stop
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
COMPOSE_PROJECT_NAME must match what orkestra.sh derives (${APP_NAME}-${ENV}), or the unit manages a different set of volumes than your deploys. Two further deliberate choices: no EnvironmentFile= (--env-file already feeds compose, and EnvironmentFile= would copy every secret in docker/.env into the unit's environment, visible in systemctl show), and stop rather than down on shutdown, so containers and their logs survive a reboot. Then:
sudo systemctl daemon-reload
sudo systemctl enable orkestra.service
sudo systemctl start orkestra.service
sudo systemctl status orkestra.service
On VM reboot, Docker comes up, then orkestra.service brings the stack up automatically. Rolling out a new version is still ./orkestra.sh deploy — the unit only guarantees the stack comes back.
Step 6 — First admin login
The core dev-token issuer (POST /dev/token, used by scripts/devtoken.sh) is auto-disabled when ENV=production — so on a real production VM you can't mint an admin token. Create the first administrator through the setup wizard instead:
# The setup endpoints (/v1/setup/*) bootstrap the first admin on a fresh install.
curl -fsS http://localhost:3000/v1/setup/status | jq .
Follow the wizard (or its UI counterpart) to create the first administrator account, then log into /admin/users to manage the rest.
For a non-production VM (
ENV=development/staging), you can instead mint a one-time token withORKESTRA_API_URL=http://localhost:3000 ./scripts/devtoken.sh administrator— but never leave a non-production env exposed publicly.
Step 7 — Log rotation
Docker's default JSON log driver grows unbounded. Configure rotation in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "5"
}
}
Restart the daemon (systemctl restart docker) — existing containers keep their old config until next recreate, so re-run docker compose up -d.
For structured JSON logs shipping straight to Loki, use the observability stack:
docker compose -f docker-compose.observability.yml --env-file .env up -d
Promtail tails Docker stdout, JSON-parses, ships to Loki. Grafana is at http://localhost:3010 (admin/admin by default — change immediately).
Step 8 — Backups
See Backup and restore for mongodump cron recipes and restore-test cadence. Redis is ephemeral (caches and sessions) — losing it logs everyone out but doesn't lose data.
A backup that lives only on the VM it protects is not a backup: follow Off-site copies to ship the nightly bundle — encrypted, since it contains docker/.env and the JWT keys — to another host.
Step 9 — Smoke tests
After every deploy and after every restart, run (scripts/health-check.sh production does the first two for you):
# Backend health (process + DB connectivity)
curl -fsS http://127.0.0.1:3000/health | jq .
# Console is serving and its runtime config points at the real API
curl -fsS http://127.0.0.1:8080/health
curl -fsS http://127.0.0.1:8080/config.js | grep apiUrl
# Setup state — `completed: true` once the first administrator exists
curl -fsS http://127.0.0.1:3000/v1/setup/status | jq .
# OpenAPI spec count
curl -fsS http://127.0.0.1:3000/openapi.json | jq '.paths | keys | length'
scripts/devtoken.sh is not an option here — POST /dev/token does not exist when ENV=production (see Step 6). Substitute 127.0.0.1 with HOST_BIND_ADDRESS if you set one.
If any of these fail, check docker compose -f docker-compose.prod.yml --env-file .env logs backend --tail 100.
Upgrade procedure
cd /opt/orkestra
./orkestra.sh deploy --yes --rebuild
deploy pulls origin/main, rebuilds the backend and console images from the checkout (production images are built locally, not pulled), restarts the app services and gates on the health check. The infra services (Mongo, Redis, RustFS) are pinned to specific image versions in the compose file — to upgrade them, edit the compose file, then docker compose up -d.
Mongo upgrades across major versions need attention — read the Mongo upgrade docs before bumping. Orkestra ships with Mongo 8.0; the schema is forward-compatible to whatever 8.x lands.
What's missing
This guide is intentionally single-VM. For multi-host setups:
- Multi-replica backend — read the K8s overview; the Go binary is stateless, so horizontal scaling works as long as sticky sessions or shared Redis sessions are configured.
- Managed databases — set
MONGO_URIandREDIS_URLto your managed-DB connection strings; remove themongodbandredisservices fromdocker-compose.infra.yml. - Object storage — the base ships RustFS (in
docker-compose.infra.yml) for local object storage. A fork that needs S3-compatible storage or PDF rendering wires that at the application layer in its own addon.