How to Install Temporal Server on Ubuntu 24.04 — Durable Workflow Orchestration on Your VPS
Modern backends spend an enormous amount of time reinventing the same wheel: retries, timeouts, compensations, idempotency keys, state machines, cron runners, dead-letter queues, and distributed sagas. Temporal collapses all of that into a single abstraction — a durable function — and this guide shows you how to host the entire platform on an Ubuntu 24.04 VPS. By the end you will have a production-grade Temporal cluster with PostgreSQL, Elasticsearch, the Web UI, mTLS between workers and server, Nginx TLS termination, archival to object storage, and real Go and TypeScript worker examples processing tasks on a queue.
Prefer a managed database or LLM? See our guides on PostgreSQL on Ubuntu and the broader self-hosted AI stack.
Table of Contents
What is Temporal?
Temporal is an open-source platform for writing durable workflows — regular code whose state is automatically persisted and replayed by the server so that crashes, restarts, and infrastructure failures become invisible to the developer. You write a function that looks synchronous ("place order, charge card, ship package, email receipt"), and Temporal guarantees it will run to completion even if every process involved dies halfway through.
Under the hood, Temporal is a distributed system composed of four services: the Frontend (gRPC entry point), the History service (workflow event log), the Matching service (task queue dispatcher), and the Worker service (internal system workers for things like archival and replication). These speak to a persistence layer — typically PostgreSQL, MySQL, or Cassandra — and an Advanced Visibility store, typically Elasticsearch or OpenSearch. SDKs exist for Go, Java, TypeScript/Node.js, Python, .NET, PHP, and Ruby, all speaking the same protocol.
Teams use Temporal for a wide range of problems: order orchestration in e-commerce, provisioning pipelines for cloud infrastructure (DataMammoth itself uses similar patterns for Contabo and OVH provisioning), AI/LLM agents that need retries and human-in-the-loop steps, financial transactions with compensations, long-running data pipelines, infrastructure runbooks, and scheduled jobs replacing cron and Airflow for stateful tasks.
Why Self-Host Temporal vs Temporal Cloud?
Temporal offers a hosted product called Temporal Cloud that gives you a managed control plane billed per "action" (workflow start, signal, activity, timer, etc.). It is a fine choice for enterprises that want zero ops, but self-hosting on a VPS has a very different cost profile and trust model.
| Factor | Temporal Cloud | Self-Hosted on Ubuntu VPS |
|---|---|---|
| Pricing model | Per-action + active-storage fees | Flat monthly VPS cost |
| Monthly cost (10M actions) | $200 - $800 depending on retention | EUR 19.99 (CloudCore Professional) |
| Data residency | AWS regions only | Any provider, any country |
| Payload visibility | Encrypted in transit, stored by Temporal | Never leaves your VPS |
| Namespaces | Paid add-on per namespace | Unlimited |
| Custom search attributes | Subject to Cloud limits | Unlimited |
| Retention | Up to 90 days | Whatever your disk allows |
| Upgrades | Automatic | You run the Docker image tag |
| SLA | 99.9% managed | What you build (HA is possible but manual) |
Architecture Overview
After this guide you will have the following components running on a single VPS:
Internet
│
┌─────┴──────┐
│ Nginx │ :443 (TLS, basic auth)
└─────┬──────┘
│
┌─────────────┼──────────────┐
│ │ │
┌─────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Web UI │ │ Frontend │ │ Metrics │
│ :8233 │ │ :7233 gRPC│ │ :9090 │
└──────────┘ └─────┬─────┘ └───────────┘
│ mTLS
┌───────────┴───────────┐
│ Temporal Server │
│ (auto-setup image) │
│ History / Matching / │
│ Frontend / Worker │
└───────┬──────────┬────┘
│ │
┌──────▼───┐ ┌───▼────────┐
│ Postgres │ │ Elastic │
│ :5432 │ │ search │
│ │ │ :9200 │
└──────────┘ └────────────┘Workers (your Go, TypeScript, or Python code) connect from inside or outside the VPS to the Frontend on port 7233, authenticate with mTLS, and poll a named task queue.
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- At least 4 vCPU and 8 GB RAM — Temporal + Postgres + Elasticsearch is memory-hungry
- At least 40 GB SSD for workflow history and the Elasticsearch index
- A domain name (for example
temporal.example.com) with an A record pointing at the VPS - Ports 22, 443, 7233, and 8233 reachable as needed (firewall details below)
- Familiarity with Docker, gRPC, and basic TLS concepts
Recommended Plan: CloudCore Professional>
For the full stack — Temporal Server, PostgreSQL, Elasticsearch, Web UI, and a couple of workers — we recommend the CloudCore Professional plan:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month>
Elasticsearch alone wants 2 GB heap; Postgres wants another 1-2 GB; Temporal services together want 1-2 GB. Going below 8 GB RAM will cause OOM kills under load.
Connect to your server:
ssh root@your-server-ipStep 1: Prepare the VPS
Update packages and install the core dependencies we will need later:
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget gnupg lsb-release ca-certificates \
software-properties-common ufw nginx jq unzip apache2-utils \
certbot python3-certbot-nginxConfigure the firewall. We allow SSH, HTTPS, and the Temporal gRPC port from anywhere (you will want to lock 7233 down to worker IPs in production):
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 7233/tcp # Temporal Frontend gRPC
sudo ufw --force enable
sudo ufw statusTune kernel parameters for Elasticsearch, which refuses to start if vm.max_map_count is too low:
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
sudo sysctl -pStep 2: Install Docker and Docker Compose
Install Docker Engine and the Compose plugin from Docker's official apt repository. For a full walk-through with hardening, see How to Install Docker on Ubuntu.
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpgecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin sudo usermod -aG docker "$USER" newgrp docker
Verify:
docker --version
docker compose versionExpected output:
Docker version 27.x.x, build xxxxxxx
Docker Compose version v2.29.xStep 3: Deploy the Temporal Stack
Create a working directory and write the Docker Compose manifest. We use the official temporalio/auto-setup image because it runs schema migrations automatically on first boot — ideal for single-node deployments.
sudo mkdir -p /opt/temporal/{config,data,certs}
sudo chown -R "$USER":"$USER" /opt/temporal
cd /opt/temporalCreate /opt/temporal/.env:
cat > .env <<'EOF'
POSTGRES_USER=temporal
POSTGRES_PASSWORD=replace-with-a-strong-password
POSTGRES_DB=temporal
ELASTIC_PASSWORD=another-strong-password
TEMPORAL_VERSION=1.25.0
ELASTICSEARCH_VERSION=7.17.22
POSTGRES_VERSION=16
EOF
chmod 600 .envCreate /opt/temporal/docker-compose.yml:
services: postgres: image: postgres:${POSTGRES_VERSION} container_name: temporal-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 networks: [temporal-net]elasticsearch: image: elasticsearch:${ELASTICSEARCH_VERSION} container_name: temporal-elasticsearch restart: unless-stopped environment: - cluster.routing.allocation.disk.threshold_enabled=true - cluster.routing.allocation.disk.watermark.low=512mb - cluster.routing.allocation.disk.watermark.high=256mb - cluster.routing.allocation.disk.watermark.flood_stage=128mb - discovery.type=single-node - ES_JAVA_OPTS=-Xms2g -Xmx2g - xpack.security.enabled=false volumes: - es-data:/usr/share/elasticsearch/data healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health || exit 1"] interval: 15s timeout: 10s retries: 10 networks: [temporal-net]
temporal: image: temporalio/auto-setup:${TEMPORAL_VERSION} container_name: temporal-server restart: unless-stopped depends_on: postgres: condition: service_healthy elasticsearch: condition: service_healthy environment: - DB=postgres12 - DB_PORT=5432 - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PWD=${POSTGRES_PASSWORD} - POSTGRES_SEEDS=postgres - ENABLE_ES=true - ES_SEEDS=elasticsearch - ES_VERSION=v7 - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/production.yaml ports: - "7233:7233" volumes: - ./config:/etc/temporal/config networks: [temporal-net]
temporal-admin-tools: image: temporalio/admin-tools:${TEMPORAL_VERSION} container_name: temporal-admin restart: unless-stopped depends_on: - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_CLI_ADDRESS=temporal:7233 stdin_open: true tty: true networks: [temporal-net]
temporal-ui: image: temporalio/ui:2.31.2 container_name: temporal-ui restart: unless-stopped depends_on: - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_CORS_ORIGINS=https://temporal.example.com ports: - "127.0.0.1:8233:8080" networks: [temporal-net]
volumes: postgres-data: es-data:
networks: temporal-net: driver: bridge
Create a dynamic config file that enables Advanced Visibility and raises a few rate limits:
mkdir -p config/dynamicconfig
cat > config/dynamicconfig/production.yaml <<'EOF'
system.advancedVisibilityWritingMode:
- value: "on"
constraints: {}
system.enableReadVisibilityFromES:
- value: true
constraints: {}
frontend.rps:
- value: 2400
constraints: {}
history.persistenceMaxQPS:
- value: 3000
constraints: {}
matching.rps:
- value: 1200
constraints: {}
EOFStart the stack:
docker compose up -dWatch the logs until Temporal reports it is ready — the auto-setup image runs schema migrations on first boot, which takes 30-60 seconds:
docker compose logs -f temporalLook for:
Temporal server started.Verify the frontend is reachable:
docker exec temporal-admin tctl cluster healthExpected output:
temporal.api.workflowservice.v1.WorkflowService: SERVINGThe Web UI is now available at http://127.0.0.1:8233 on the VPS (we will add TLS in Step 9).
Step 4: Install the Temporal CLI
The admin-tools container gives you tctl (legacy) and temporal (new CLI) inside Docker, but you usually want the CLI on the host too. Install the modern temporal binary:
curl -sSf https://temporal.download/cli.sh | sh
sudo mv ~/.temporalio/bin/temporal /usr/local/bin/
temporal --versionExpected output:
temporal version 1.3.0Point it at your local server:
export TEMPORAL_ADDRESS=127.0.0.1:7233
echo 'export TEMPORAL_ADDRESS=127.0.0.1:7233' >> ~/.bashrcTest it:
temporal operator cluster describeStep 5: Create Namespaces
Namespaces are Temporal's unit of isolation — different teams, environments (dev/staging/prod), or products all live in separate namespaces with their own retention policies and search attributes.
Create one for your application and another for staging:
temporal operator namespace create \ --namespace billing \ --retention 30d \ --description "Production billing workflows"
temporal operator namespace create \ --namespace billing-staging \ --retention 7d \ --description "Staging environment for billing"
List namespaces:
temporal operator namespace listRegister a custom search attribute you can later filter on in the Web UI:
temporal operator search-attribute create \ --namespace billing \ --name CustomerTier \ --type Keyword
temporal operator search-attribute create \ --namespace billing \ --name OrderTotal \ --type Double
Step 6: Write a Go Worker
Let's write a worker that runs an OrderWorkflow with two activities: ChargeCard and ShipOrder. On the VPS or your developer laptop, initialize a module:
mkdir -p ~/billing-worker && cd ~/billing-worker
go mod init example.com/billing
go get go.temporal.io/sdk@latestCreate workflow.go:
package billingimport ( "time"
"go.temporal.io/sdk/temporal" "go.temporal.io/sdk/workflow" )
type Order struct { ID string CustomerID string AmountUSD float64 }
func OrderWorkflow(ctx workflow.Context, order Order) (string, error) { ao := workflow.ActivityOptions{ StartToCloseTimeout: 30 * time.Second, RetryPolicy: &temporal.RetryPolicy{ InitialInterval: time.Second, BackoffCoefficient: 2.0, MaximumInterval: time.Minute, MaximumAttempts: 5, }, } ctx = workflow.WithActivityOptions(ctx, ao)
var chargeID string if err := workflow.ExecuteActivity(ctx, ChargeCard, order).Get(ctx, &chargeID); err != nil { return "", err }
var tracking string if err := workflow.ExecuteActivity(ctx, ShipOrder, order).Get(ctx, &tracking); err != nil { // compensate: refund the charge _ = workflow.ExecuteActivity(ctx, RefundCard, chargeID).Get(ctx, nil) return "", err }
return tracking, nil }
Create activities.go:
package billingimport ( "context" "fmt" )
func ChargeCard(ctx context.Context, o Order) (string, error) { // call Stripe, Adyen, etc. return fmt.Sprintf("ch_%s", o.ID), nil }
func ShipOrder(ctx context.Context, o Order) (string, error) { return fmt.Sprintf("TRACK-%s", o.ID), nil }
func RefundCard(ctx context.Context, chargeID string) error { return nil }
Create cmd/worker/main.go:
package mainimport ( "log"
"example.com/billing" "go.temporal.io/sdk/client" "go.temporal.io/sdk/worker" )
func main() { c, err := client.Dial(client.Options{ HostPort: "127.0.0.1:7233", Namespace: "billing", }) if err != nil { log.Fatalf("dial: %v", err) } defer c.Close()
w := worker.New(c, "billing-queue", worker.Options{}) w.RegisterWorkflow(billing.OrderWorkflow) w.RegisterActivity(billing.ChargeCard) w.RegisterActivity(billing.ShipOrder) w.RegisterActivity(billing.RefundCard)
if err := w.Run(worker.InterruptCh()); err != nil { log.Fatalf("worker: %v", err) } }
Build and run:
go build -o worker ./cmd/worker
./workerIn another shell, start a workflow using the CLI:
temporal workflow start \
--namespace billing \
--task-queue billing-queue \
--type OrderWorkflow \
--workflow-id order-1001 \
--input '{"ID":"1001","CustomerID":"cus_42","AmountUSD":129.90}'Watch progress:
temporal workflow show --namespace billing --workflow-id order-1001The workflow should complete and return a tracking number. If you kill the worker mid-flight, the state survives — restart the worker and Temporal replays the history to resume exactly where it left off.
Step 7: Write a TypeScript Worker
For TypeScript, initialize a new Node project:
mkdir -p ~/billing-worker-ts && cd ~/billing-worker-ts
npm init -y
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm install -D typescript @types/node ts-node
npx tsc --initCreate src/activities.ts:
export async function chargeCard(orderId: string, amount: number): Promise<string> { // Replace with your payment gateway call. returnch_${orderId}; }export async function shipOrder(orderId: string): Promise<string> { return
TRACK-${orderId}; }
export async function refundCard(chargeId: string): Promise<void> { / no-op / }
Create src/workflows.ts:
import { proxyActivities } from '@temporalio/workflow'; import type * as activities from './activities';const { chargeCard, shipOrder, refundCard } = proxyActivities<typeof activities>({ startToCloseTimeout: '30 seconds', retry: { initialInterval: '1s', backoffCoefficient: 2, maximumInterval: '1m', maximumAttempts: 5, }, });
export interface OrderInput { id: string; customerId: string; amountUsd: number; }
export async function orderWorkflow(order: OrderInput): Promise<string> { const chargeId = await chargeCard(order.id, order.amountUsd); try { return await shipOrder(order.id); } catch (err) { await refundCard(chargeId); throw err; } }
Create src/worker.ts:
import { Worker } from '@temporalio/worker'; import * as activities from './activities';async function run() { const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), activities, taskQueue: 'billing-queue', namespace: 'billing', connection: { address: '127.0.0.1:7233' } as any, }); await worker.run(); }
run().catch((err) => { console.error(err); process.exit(1); });
Run:
npx ts-node src/worker.tsYou can now start workflows from a Node client, the CLI, or any other SDK — all three are interchangeable because Temporal speaks a single protocol.
Step 8: Enable mTLS
By default the frontend accepts unauthenticated gRPC. In production you must require mutual TLS so only workers holding a valid client certificate can connect.
Generate a private CA and server + client certs using OpenSSL:
cd /opt/temporal/certsCA
openssl genrsa -out ca.key 4096 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ -subj "/CN=Temporal Internal CA" -out ca.crtServer cert for the Temporal frontend
openssl genrsa -out server.key 4096 openssl req -new -key server.key -subj "/CN=temporal.example.com" -out server.csr cat > server.ext <<EOF subjectAltName = DNS:temporal.example.com,DNS:temporal,IP:127.0.0.1 extendedKeyUsage = serverAuth EOF openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 825 -sha256 -extfile server.extClient cert for a worker identity
openssl genrsa -out client.key 4096 openssl req -new -key client.key -subj "/CN=billing-worker" -out client.csr cat > client.ext <<EOF extendedKeyUsage = clientAuth EOF openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out client.crt -days 825 -sha256 -extfile client.ext
chmod 600 *.key
Update the Temporal service block in docker-compose.yml to mount the certs and enable TLS on the frontend. Add these lines under the temporal service:
environment:
# ... existing vars ...
- TEMPORAL_TLS_REQUIRE_CLIENT_AUTH=true
- TEMPORAL_TLS_SERVER_CERT=/etc/temporal/certs/server.crt
- TEMPORAL_TLS_SERVER_KEY=/etc/temporal/certs/server.key
- TEMPORAL_TLS_SERVER_CA_CERT=/etc/temporal/certs/ca.crt
volumes:
- ./config:/etc/temporal/config
- ./certs:/etc/temporal/certs:roTemporal's official Helm chart and full TLS config expects a config/tls.yaml — for a richer setup, drop the following into config/tls.yaml and reference it via TEMPORAL_CONFIG_FILE:
global:
tls:
frontend:
server:
certFile: /etc/temporal/certs/server.crt
keyFile: /etc/temporal/certs/server.key
requireClientAuth: true
clientCaFiles:
- /etc/temporal/certs/ca.crt
client:
serverName: temporal.example.com
rootCaFiles:
- /etc/temporal/certs/ca.crtRestart:
docker compose up -d temporalYour Go worker now needs to present the client cert:
cert, _ := tls.LoadX509KeyPair("/opt/temporal/certs/client.crt", "/opt/temporal/certs/client.key") caBytes, _ := os.ReadFile("/opt/temporal/certs/ca.crt") caPool := x509.NewCertPool() caPool.AppendCertsFromPEM(caBytes)
c, _ := client.Dial(client.Options{ HostPort: "temporal.example.com:7233", Namespace: "billing", ConnectionOptions: client.ConnectionOptions{ TLS: &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caPool, ServerName: "temporal.example.com", }, }, })
Unauthenticated workers will be rejected with TLS handshake error.
Step 9: Expose the Web UI with Nginx TLS
The Web UI container binds to 127.0.0.1:8233. Expose it publicly with Nginx, Let's Encrypt, and basic auth.
Create an htpasswd file:
sudo htpasswd -c /etc/nginx/.temporal-htpasswd adminCreate /etc/nginx/sites-available/temporal:
server { listen 80; server_name temporal.example.com; return 301 https://$host$request_uri; }server { listen 443 ssl http2; server_name temporal.example.com;
ssl_certificate /etc/letsencrypt/live/temporal.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/temporal.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always;
client_max_body_size 10m;
location / { auth_basic "Temporal Web UI"; auth_basic_user_file /etc/nginx/.temporal-htpasswd;
proxy_pass http://127.0.0.1:8233; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 600s; } }
Enable it and get a certificate:
sudo ln -s /etc/nginx/sites-available/temporal /etc/nginx/sites-enabled/
sudo nginx -t
sudo certbot --nginx -d temporal.example.com --redirect --agree-tos -m [email protected] --non-interactive
sudo systemctl reload nginxBrowse to https://temporal.example.com, authenticate, and you should see the full Temporal Web UI with your billing namespace, workflows list, and search attributes.
Step 10: Configure Archival
Archival copies closed workflow histories and visibility records to long-term object storage so you can keep them beyond the namespace retention period without bloating Postgres. Temporal supports filesystem, S3, and GCS archivers out of the box.
Create an S3 bucket (any S3-compatible provider works — AWS S3, Contabo Object Storage, Backblaze B2, MinIO). Then add an archival config to your Temporal service environment:
environment:
- ARCHIVAL_HISTORY_STATE=enabled
- ARCHIVAL_HISTORY_URI=s3://temporal-archive/history
- ARCHIVAL_VISIBILITY_STATE=enabled
- ARCHIVAL_VISIBILITY_URI=s3://temporal-archive/visibility
- AWS_ACCESS_KEY_ID=xxxxx
- AWS_SECRET_ACCESS_KEY=xxxxx
- AWS_REGION=eu-central-1
- AWS_S3_ENDPOINT=https://eu2.contabostorage.com # if using ContaboEnable archival on the namespace:
temporal operator namespace update \
--namespace billing \
--history-archival-state Enabled \
--history-archival-uri s3://temporal-archive/history \
--visibility-archival-state Enabled \
--visibility-archival-uri s3://temporal-archive/visibilityFrom now on, every workflow that closes will have its full history uploaded to the bucket as gzipped JSON, queryable via the Web UI's Archived tab.
Task Queues and Scaling
Task queues are the primary scaling lever in Temporal. Every worker declares which queues it polls, and you can shape your system around them:
- Per-capability queues —
billing-queue,email-queue,ml-inference-queue, each polled by a dedicated fleet. - Per-priority queues —
orders-priority,orders-bulk, letting you starve low-priority work under load. - Per-hardware queues —
gpu-queuepolled only by workers on GPU VPS plans,cpu-queuefor everything else. - Per-tenant queues —
tenant-acme-queue,tenant-globex-queue, so a noisy tenant cannot starve the others.
temporalio/auto-setup image is great for single-node; for multi-node, use the separate temporalio/server image with the official Helm chart or an equivalent Compose split.A typical production topology on VPS-Server.host looks like:
- 1x CloudCore Professional running Temporal Server + Postgres + Elasticsearch (this guide)
- 2-4x Starter VPS running worker processes, each polling the relevant task queues
- 1x CloudCore Professional running a read-replica of Postgres for DR
Observability
Temporal exposes Prometheus-compatible metrics on port 9090. Add this to your Compose service:
ports:
- "127.0.0.1:9090:9090"
environment:
- PROMETHEUS_ENDPOINT=0.0.0.0:9090Key metrics to scrape:
temporal_request— RPS per servicetemporal_request_latency— histogram of gRPC call latencypersistence_requests— persistence-layer pressuretask_latency— activity/workflow task dispatch latency
For structured logs, point Temporal at stderr (the default) and ship the Docker logs to Loki, Seq, or Elasticsearch with a logging driver.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
max virtual memory areas vm.max_map_count [65530] is too low | Elasticsearch refuses to start on small kernels | sudo sysctl -w vm.max_map_count=262144 and persist in /etc/sysctl.conf |
connection refused on port 7233 | Temporal still running schema migration | Wait 30-60s; docker compose logs temporal until you see Temporal server started |
ssl: handshake error from worker | mTLS enabled but worker not presenting client cert | Load the client cert + key + CA in the SDK's ConnectionOptions.TLS |
| Web UI shows "Advanced Visibility is not enabled" | Dynamic config missing or ES unhealthy | Confirm system.advancedVisibilityWritingMode=on and curl http://localhost:9200/_cluster/health returns green/yellow |
Workflow stuck in Running forever | No worker polling its task queue | Confirm worker is up; check Web UI "Task Queues" tab for pollers; match namespace + queue name exactly |
| Postgres disk full | History retention too high, archival disabled | Lower --retention on the namespace; enable archival (Step 10); VACUUM FULL on Postgres |
activity timeout errors | Activity exceeded StartToCloseTimeout | Increase timeouts in ActivityOptions, or send heartbeats with activity.RecordHeartbeat for long-running work |
| High CPU on Temporal server | Too many concurrent workflow history shards | Raise numHistoryShards on first deploy only (cannot change later); scale Postgres vertically |
docker compose logs -f --tail=100FAQ
Why self-host Temporal instead of using Temporal Cloud?
Self-hosting eliminates per-action billing, keeps workflow payloads and PII inside your own VPS, removes network egress fees, and gives you full control over retention, namespaces, and archival. Temporal Cloud is excellent for enterprises that want a managed control plane, but for most teams a single CloudCore Professional VPS running Docker Compose is more than enough to run millions of workflow events per month at a flat monthly cost.
Do I need Elasticsearch to run Temporal?
Elasticsearch is required for Advanced Visibility — the ability to search workflows by custom search attributes and status in the Web UI. It is technically optional if you only use the basic visibility store in PostgreSQL, but every production deployment should run Elasticsearch because the Web UI and tctl queries depend on it for filtering and reporting. If you are memory-constrained, OpenSearch 2.x is a drop-in replacement.
What is a task queue in Temporal?
A task queue is the routing mechanism Temporal uses to deliver workflow and activity tasks to workers. You pick a name (for example, billing-queue or ml-inference), workers poll that queue, and the Temporal service dispatches tasks to whichever worker is available. Task queues let you partition workload by capability, priority, or hardware tier — it is the fundamental unit of horizontal scaling in Temporal.
How does mTLS work between Temporal workers and the server?
Temporal supports mutual TLS on its frontend gRPC endpoint. You generate a certificate authority, issue a server certificate for the frontend, and issue client certificates for each worker or SDK user. The frontend verifies incoming client certificates against the CA, and workers verify the server certificate — preventing unauthorized code from submitting or polling workflows. For larger teams, rotate client certs quarterly and issue one cert per service identity so you can revoke individually.
How do I back up Temporal state?
Temporal persistence lives in PostgreSQL (workflow histories, namespaces, clusters) and Elasticsearch (visibility index). Back up PostgreSQL with pg_dump or a volume snapshot nightly, and use Elasticsearch's snapshot API for the visibility index. Enabling archival to S3 adds a secondary, append-only copy of every closed workflow history — so even if your Postgres is lost, closed workflows are still auditable. See our PostgreSQL install guide for pg_basebackup and WAL archiving patterns.
Can I upgrade Temporal in place?
Yes. Bump TEMPORAL_VERSION in .env, run docker compose pull && docker compose up -d, and the auto-setup image will apply any schema migrations on start. Read the Temporal release notes first — major versions sometimes require intermediate upgrades.
How many workflows can a single VPS handle?
Rough numbers on a CloudCore Professional (6 vCPU, 12 GB RAM) with this stack: 500-2000 workflow starts per second, 10M+ open workflows, and 50-100M events per day before you need to shard history or split services. The bottleneck at scale is usually Postgres IOPS — move to NVMe-backed storage and partition by namespace before adding a second Temporal node.
Does Temporal replace Airflow, Celery, or Sidekiq?
In many cases, yes. Temporal is strictly more powerful than Celery/Sidekiq because it persists full function state across failures, and it is more flexible than Airflow because workflows are arbitrary code rather than static DAGs. The trade-off is operational complexity — Temporal runs two stateful systems (Postgres + ES) where Celery only needs Redis. For simple fire-and-forget jobs, Celery is still lighter; for anything with retries, compensations, or human-in-the-loop, Temporal wins.
Next Steps
Now that Temporal is running on your VPS, here are recommended next steps:
- Add scheduled workflows — Temporal Schedules replace cron with durable, idempotent triggers. Run
temporal schedule create --helpto get started. - Integrate with your existing stack — Call Temporal from your main API to kick off long-running work. The SDK client is just a gRPC call.
- Deploy a worker fleet — Spin up 2-3 Starter VPS nodes and run worker processes there, keeping the Temporal server node dedicated to orchestration.
- Set up HA Postgres — Use streaming replication and a PostgreSQL primary + replica setup so a single VPS failure does not lose workflow history.
- Scale out Elasticsearch — For deployments over 50M events/day, move to a 3-node Elasticsearch cluster for visibility index resilience.
- Learn Temporal patterns — The official Temporal docs cover sagas, continue-as-new, signals, queries, updates, and child workflows. The patterns section is the best ROI hour you will spend on the platform.
- Monitor production — Pair this install with our monitoring stack guide for Prometheus, Grafana, and alerting tuned for Temporal's metrics.
Ready to deploy Temporal?>
Spin up a CloudCore Professional VPS with 6 vCPU, 12 GB RAM, and 100 GB NVMe for EUR 19.99/month — enough headroom for Temporal Server, PostgreSQL, Elasticsearch, the Web UI, and your first worker fleet, with room to grow.>
- Ubuntu 24.04 LTS pre-installed
- NVMe SSD for fast Postgres + Elasticsearch
- Unmetered bandwidth for worker traffic
- Full root access, full data sovereignty>
Deploy Your Temporal VPS Now — or read the official Temporal documentation for deeper platform reference.