CS

Temporal (Self-Hosted)

Running a self-hosted Temporal stack with Docker Compose on a single VPS: the CLI via admin-tools, namespaces, workers, and the self-host gotchas.

What this covers

Operating a self-hosted Temporal stack that runs as Docker Compose services on a single VPS: bringing the services up and down, checking health, running the Temporal CLI through the admin-tools container, managing namespaces, running and inspecting workers, and the gotchas specific to a single-VPS deployment (no-auth/firewalling, UI CORS, Postgres persistence and backups, schema migrations on upgrade). This is the operational layer — for how to write Temporal workflow code (determinism, activity patterns, retries, versioning, testing), see the official Temporal documentation.

When to use it

  • Standing up or operating a self-hosted Temporal stack on a VPS
  • Running Temporal CLI commands against the server (listing/starting/signaling/terminating workflows)
  • Creating or inspecting namespaces, or (re)building and restarting workers
  • Troubleshooting a single-VPS deployment: no-auth exposure, UI showing no namespaces, worker connection refused, database persistence, or an upgrade schema mismatch

Learn the actual stack first

Before running anything, read the project’s Compose and env files to get the real service names, ports, and database — don’t assume. Check docker-compose.yml / compose.yaml (plus any overrides) and .env, and identify:

  • the Temporal server service (image temporalio/auto-setup) and the gRPC frontend port (default 7233)
  • the admin-tools service (image temporalio/admin-tools) — where the temporal CLI lives
  • the UI service (image temporalio/ui) and its port (default 8080)
  • the database service (Postgres / MySQL / Cassandra) and whether Elasticsearch is used for visibility
  • the worker service(s), if defined in Compose
  • the namespace(s) in use

Canonical defaults if a value isn’t found (verify against the real file): services temporal, temporal-admin-tools, temporal-ui, postgresql; gRPC 7233; UI 8080. Workers and clients reach the server at temporal:7233 inside the Compose network, or 127.0.0.1:7233 on the host.

Running the stack

Run from the directory holding the Compose file (on the VPS, over SSH):

docker compose up -d                 # start
docker compose ps                    # status
docker compose logs -f temporal      # follow server logs
docker compose restart temporal      # restart one service
docker compose down                  # stop (keeps volumes)

Quick health check — the frontend is reachable if this returns:

docker compose exec temporal-admin-tools temporal operator namespace list

Temporal CLI (via admin-tools)

Run CLI commands inside the admin-tools container, which is preconfigured with TEMPORAL_ADDRESS=temporal:7233:

docker compose exec temporal-admin-tools temporal <command>

Common operations (prefix each with docker compose exec temporal-admin-tools):

# workflows
temporal workflow list
temporal workflow describe  --workflow-id <id>
temporal workflow show      --workflow-id <id>     # full event history
temporal workflow start     --task-queue <q> --type <Name> --workflow-id <id> --input '<json>'
temporal workflow signal    --workflow-id <id> --name <signal> --input '<json>'
temporal workflow query     --workflow-id <id> --type <query>
temporal workflow terminate --workflow-id <id> --reason "<why>"

# task queues / namespaces
temporal task-queue describe      --task-queue <q>
temporal operator namespace list
temporal operator namespace describe --namespace <ns>

The Web UI (default http://<host>:8080) is the easier way to browse executions and inspect histories; the CLI is for scripting and headless operations on the VPS.

Namespaces

The auto-setup image registers the default namespace on first start. Create others with a retention period:

docker compose exec temporal-admin-tools \
  temporal operator namespace create --retention 7d <namespace>

Retention is the main storage-cost lever — start at 7d and raise it only for namespaces you actually query far back.

Workers

A worker is its own Compose service, built from the app image and pointed at the server:

worker:
  build: { context: ., dockerfile: Dockerfile.worker }
  environment:
    TEMPORAL_HOST: temporal:7233
  depends_on: [temporal]
  restart: unless-stopped

After changing workflow/activity code, rebuild and restart the worker:

docker compose up -d --build worker
docker compose logs -f worker

Scale stateless workers with docker compose up -d --scale worker=N.

Self-host gotchas (single VPS)

  • Security — no auth by default. The OSS server has no authentication; anyone who can reach 7233 can do anything. Keep 7233 bound to 127.0.0.1 and firewalled, and run workers on the same host — or reach the frontend over a private tunnel (e.g. WireGuard). Put the UI / gRPC behind a TLS reverse proxy (NGINX) if it must be exposed.
  • UI shows “no namespaces.” Usually CORS — set TEMPORAL_CORS_ORIGINS on the UI service to the real (HTTPS) hostname.
  • “Connection refused” from a worker. 7233 is bound to localhost by design — use temporal:7233 inside the network, or run the worker on the host.
  • Database persistence. The DB holds all workflow state, so its volume must be named and persistent (e.g. pgdata:/var/lib/postgresql/data). Back it up and ship it off-host, on a cron:
    docker compose exec -T postgresql pg_dump -U temporal temporal | gzip > temporal-$(date +%F).sql.gz
    
  • Upgrades. Bump the auto-setup / admin-tools / ui image versions together, then run the schema migration from admin-tools (temporal-sql-tool ... update-schema). Skipping it causes schema-mismatch errors.
  • Small VPS / Elasticsearch. ES is memory-hungry; on small tiers either skip ES (use the DB for visibility) or cap heap with ES_JAVA_OPTS=-Xms1g -Xmx1g to avoid OOM kills.