Hatchet is an open-source background task and workflow orchestration platform written in Go. It is a strong fit for teams who want Temporal-style durable execution and DAG workflows without paying for managed Temporal Cloud, and who do not want the operational footprint of a full Temporal cluster. This guide walks through deploying the Hatchet control plane on a RamNode VPS using Docker Compose, then connecting a worker. The setup includes TLS via a reverse proxy, firewall hardening, and a backup strategy for the PostgreSQL data that holds your workflow state.
What you will build
By the end of this guide you will have:
- Docker Engine and Docker Compose installed on Ubuntu 24.04
- A full Hatchet control plane (PostgreSQL, RabbitMQ, API server, engine, dashboard) running under Docker Compose
- nginx terminating TLS in front of the dashboard, API, and gRPC engine
- UFW restricting the public surface area to SSH, HTTP, and HTTPS
- A running worker that picks up tasks from the engine
- Daily encrypted PostgreSQL backups
Choosing between Hatchet Lite and the full stack
Hatchet ships in two flavors. Pick based on throughput, not aesthetics.
| Option | When to use | What runs |
|---|---|---|
| Hatchet Lite | Development, internal tooling, low-throughput production workloads (single-digit workflows per second) | Single bundled image plus PostgreSQL |
| Full Docker Compose | Production workloads above a few tens of workflows per second, or when you want RabbitMQ as the broker for backpressure and durability | Separate API, engine, dashboard, PostgreSQL, RabbitMQ |
This guide uses the full Docker Compose path. If you want Lite instead, the broad strokes (Docker install, firewall, nginx, TLS, backups) all carry over, and the Hatchet docs cover the Lite-specific compose file.
RamNode plan sizing
Hatchet's resource pressure comes from PostgreSQL and RabbitMQ, not the Go services themselves. The engine and API are lightweight.
| Workload | Suggested RamNode plan | Notes |
|---|---|---|
| Hatchet Lite, internal tools | Premium NVMe VPS, 4 GB RAM, 2 vCPU | One bundled container plus Postgres, comfortable |
| Full stack, low-volume production | Premium NVMe VPS, 8 GB RAM, 4 vCPU | Headroom for Postgres connections, RabbitMQ queues, and a couple of local workers |
| Full stack, sustained throughput | Premium NVMe VPS, 16 GB RAM, 4 to 8 vCPU | Workers should live on separate VPSes once you scale out |
NVMe matters here. PostgreSQL is the bottleneck under load, and Hatchet's workflow history grows over time.
Prerequisites
- A RamNode VPS running Ubuntu 24.04 LTS
- Two DNS records pointed at the VPS public IP: one for the dashboard (
hatchet.example.com) and one for the engine gRPC endpoint (engine.example.com). You can use a single domain if you prefer to multiplex via paths, but two records keeps nginx config straightforward. - SSH key access as a non-root sudo user
- Ports 80 and 443 reachable for ACME and HTTPS, plus port 7077 if you plan to expose the engine gRPC publicly for remote workers
Step 1: Initial server hardening
sudo apt update && sudo apt upgrade -y
sudo timedatectl set-timezone UTC
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgradesRestrict the firewall to SSH for now.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status verboseStep 2: Install Docker Engine and Docker Compose
Add the official Docker repository and install the engine plus the Compose plugin.
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") 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-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"Log out and back in so your shell picks up the docker group. Confirm both tools work.
docker --version
docker compose versionStep 3: Lay out the Hatchet project
Create a working directory that will hold the Compose file, secrets, and persistent data.
sudo mkdir -p /opt/hatchet
sudo chown "$USER":"$USER" /opt/hatchet
cd /opt/hatchetGenerate strong random passwords for PostgreSQL and RabbitMQ before writing the compose file.
openssl rand -base64 24
openssl rand -base64 24Save those somewhere safe. You will paste them into the compose file below.
Step 4: Write the Docker Compose file
Create /opt/hatchet/docker-compose.yml. This is adapted from the upstream production reference: PostgreSQL as primary data store, RabbitMQ as the broker, plus the setup-config, migration, API, engine, and dashboard services.
name: hatchet
x-hatchet-environment: &hatchet-env
DATABASE_URL: "postgresql://hatchet:${POSTGRES_PASSWORD}@postgres:5432/hatchet?sslmode=disable"
DATABASE_POSTGRES_PORT: "5432"
DATABASE_POSTGRES_HOST: postgres
DATABASE_POSTGRES_USERNAME: hatchet
DATABASE_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
SERVER_TASKQUEUE_RABBITMQ_URL: amqp://hatchet:${RABBITMQ_PASSWORD}@rabbitmq:5672/
SERVER_AUTH_COOKIE_DOMAIN: hatchet.example.com
SERVER_AUTH_COOKIE_INSECURE: "false"
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
SERVER_GRPC_INSECURE: "false"
SERVER_GRPC_BROADCAST_ADDRESS: engine.example.com:443
SERVER_GRPC_PORT: "7070"
SERVER_URL: https://hatchet.example.com
SERVER_AUTH_SET_EMAIL_VERIFIED: "true"
services:
postgres:
image: postgres:15.6
command: postgres -c 'max_connections=1000'
restart: always
environment:
POSTGRES_USER: hatchet
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: hatchet
volumes:
- hatchet_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -d hatchet -U hatchet"]
interval: 10s
timeout: 10s
retries: 5
rabbitmq:
image: rabbitmq:3-management
restart: always
environment:
RABBITMQ_DEFAULT_USER: hatchet
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- hatchet_rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 10s
timeout: 10s
retries: 5
setup-config:
image: ghcr.io/hatchet-dev/hatchet/hatchet-admin:latest
command: /hatchet/hatchet-admin quickstart --skip certs --generated-config-dir /hatchet/config --overwrite=false
environment:
<<: *hatchet-env
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
migration:
image: ghcr.io/hatchet-dev/hatchet/hatchet-migrate:latest
environment:
<<: *hatchet-env
depends_on:
postgres:
condition: service_healthy
hatchet-engine:
image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:latest
command: /hatchet/hatchet-engine --config /hatchet/config
restart: always
environment:
<<: *hatchet-env
ports:
- "127.0.0.1:7077:7070"
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
depends_on:
setup-config:
condition: service_completed_successfully
migration:
condition: service_completed_successfully
hatchet-api:
image: ghcr.io/hatchet-dev/hatchet/hatchet-api:latest
command: /hatchet/hatchet-api --config /hatchet/config
restart: always
environment:
<<: *hatchet-env
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
depends_on:
setup-config:
condition: service_completed_successfully
migration:
condition: service_completed_successfully
hatchet-frontend:
image: ghcr.io/hatchet-dev/hatchet/hatchet-frontend:latest
restart: always
caddy:
image: caddy:2.7-alpine
restart: always
ports:
- "127.0.0.1:8080:8080"
command: |
caddy reverse-proxy --from :8080 --to hatchet-frontend:80
depends_on:
- hatchet-frontend
- hatchet-api
volumes:
hatchet_postgres_data:
hatchet_rabbitmq_data:
hatchet_certs:
hatchet_config:Note that the engine gRPC port is bound to 127.0.0.1:7077 and the internal Caddy fronts the dashboard on 127.0.0.1:8080. The public-facing nginx layer in the next step will terminate TLS and reverse-proxy to these loopback ports.
Create /opt/hatchet/.env for secrets:
cat > /opt/hatchet/.env <<EOF
POSTGRES_PASSWORD=paste_your_first_openssl_value_here
RABBITMQ_PASSWORD=paste_your_second_openssl_value_here
EOF
chmod 600 /opt/hatchet/.envStep 5: Bring up the stack
cd /opt/hatchet
docker compose up -d
docker compose ps
docker compose logs -f --tail=50 hatchet-apiWatch the logs until the API service reports it is serving on port 8080. The setup-config and migration jobs will run once and exit, which is expected. If you see authentication errors, double-check that POSTGRES_PASSWORD and RABBITMQ_PASSWORD in .env match what the services were started with. A clean reset is docker compose down -v (this wipes volumes) followed by docker compose up -d.
Step 6: nginx, TLS, and firewall
Install nginx and certbot, then open HTTP/HTTPS in the firewall.
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full'
sudo ufw statusCreate /etc/nginx/sites-available/hatchet:
# Dashboard and REST API
server {
listen 80;
server_name hatchet.example.com;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name hatchet.example.com;
client_max_body_size 64M;
location / {
proxy_pass http://127.0.0.1:8080;
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 3600s;
proxy_send_timeout 3600s;
}
}Create /etc/nginx/sites-available/hatchet-engine for the gRPC endpoint. gRPC needs HTTP/2 end-to-end, so use grpc_pass, not proxy_pass:
server {
listen 80;
server_name engine.example.com;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name engine.example.com;
http2_max_concurrent_streams 256;
client_max_body_size 16M;
grpc_read_timeout 3600s;
grpc_send_timeout 3600s;
location / {
grpc_pass grpc://127.0.0.1:7077;
grpc_set_header X-Real-IP $remote_addr;
grpc_set_header Host $host;
}
}Enable both sites and test.
sudo ln -s /etc/nginx/sites-available/hatchet /etc/nginx/sites-enabled/hatchet
sudo ln -s /etc/nginx/sites-available/hatchet-engine /etc/nginx/sites-enabled/hatchet-engine
sudo nginx -t
sudo systemctl reload nginxIssue certificates for both hostnames in one shot:
sudo certbot --nginx \
-d hatchet.example.com \
-d engine.example.com \
--non-interactive --agree-tos -m you@example.com --redirect
sudo certbot renew --dry-runStep 7: Bootstrap your first tenant and API token
Visit https://hatchet.example.com and create an account. Magic-link delivery is disabled in this stack because RamNode does not allow mail services on the VPS, so the dashboard's local signup form using a password is the path you want. The compose file already sets SERVER_AUTH_SET_EMAIL_VERIFIED=true, which bypasses verification.
Once logged in, create a tenant, then go to Settings → API Tokens → Create. Copy the token. You will use it to authenticate workers and the CLI.
Step 8: Run a worker
Workers can live on the same VPS for low-volume workloads. For anything sustained, run workers on a separate RamNode VPS, or on multiple boxes, and point them at engine.example.com:443.
Here is a minimal Python worker. Create a directory and virtualenv first:
sudo apt install -y python3-venv
mkdir -p /opt/hatchet-worker && cd /opt/hatchet-worker
python3 -m venv venv && source venv/bin/activate
pip install hatchet-sdk python-dotenvCreate worker.py:
import asyncio
from hatchet_sdk import Hatchet
hatchet = Hatchet()
@hatchet.workflow(on_events=["user:created"])
class WelcomeWorkflow:
@hatchet.step()
def greet(self, context):
name = context.workflow_input().get("name", "friend")
return {"message": f"Welcome, {name}"}
async def main():
worker = hatchet.worker("welcome-worker", max_runs=5)
worker.register_workflow(WelcomeWorkflow())
await worker.async_start()
if __name__ == "__main__":
asyncio.run(main())Create .env in the same directory:
HATCHET_CLIENT_TOKEN=<paste_api_token_from_dashboard>
HATCHET_CLIENT_TLS_STRATEGY=tlsRun it:
python worker.pyYou should see the worker log a successful registration. Trigger a test event from the dashboard or via the SDK to confirm the WelcomeWorkflow runs end to end. For production, wrap the worker in a systemd unit so it restarts on failure and starts at boot.
Step 9: Backups
PostgreSQL holds all workflow state. Lose it and you lose history. Configure encrypted nightly backups with pg_dump from inside the postgres container.
Create /usr/local/bin/backup-hatchet.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/hatchet"
RETENTION_DAYS=14
DATE=$(date -u +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
docker compose -f /opt/hatchet/docker-compose.yml exec -T postgres \
pg_dump -U hatchet -d hatchet --format=custom \
| gzip > "$BACKUP_DIR/hatchet-$DATE.dump.gz"
find "$BACKUP_DIR" -type f -name 'hatchet-*.dump.gz' -mtime +"$RETENTION_DAYS" -deleteMake it executable and schedule via systemd timer (same pattern as the Manticore guide):
sudo chmod +x /usr/local/bin/backup-hatchet.sh/etc/systemd/system/hatchet-backup.service:
[Unit]
Description=Hatchet PostgreSQL backup
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-hatchet.sh/etc/systemd/system/hatchet-backup.timer:
[Unit]
Description=Daily Hatchet backup
[Timer]
OnCalendar=daily
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now hatchet-backup.timerFor offsite copies, append an rclone copy /var/backups/hatchet remote:hatchet-backups/ line to the script. RabbitMQ state does not need to be backed up: it holds in-flight messages, not durable workflow history.
Step 10: Hardening and ongoing operations
- Don't expose the database or broker. The compose file does not publish PostgreSQL or RabbitMQ ports. Keep it that way. If you need to inspect them, port-forward over SSH.
- Rotate the API token. Issue a fresh token at least quarterly and update workers. Hatchet supports multiple active tokens, so you can roll without downtime.
- Watch disk. Workflow history accumulates. Monitor
/var/lib/docker/volumesand prune old workflows from the dashboard or via the API once your retention needs are clear. - Restrict admin UI by IP. If only your team needs the dashboard, add an
allow/denyblock to the nginx server forhatchet.example.com. The gRPC engine endpoint should stay open since workers connect to it from anywhere. - Upgrades. Bump image tags in the compose file deliberately. Read the Hatchet changelog before upgrading minor versions, then
docker compose pull && docker compose up -d. Themigrationservice handles schema changes idempotently.
Troubleshooting
- Workers fail to connect with
unavailable: ssl handshake. ConfirmHATCHET_CLIENT_TLS_STRATEGY=tlsis set, and that nginx is fronting the engine withgrpc_pass, notproxy_pass. Test withgrpcurl engine.example.com:443 list. - Dashboard returns 502. The frontend container is unhealthy or the API is not yet ready.
docker compose logs hatchet-apishould show database connection success. pq: SSL is requiredin API logs. Either setsslmode=disableinDATABASE_URLfor an internal Postgres, or run an external Postgres with the appropriate SSL cert mounted into the API container. The compose file above usessslmode=disablesince Postgres is on the internal Docker network.- High CPU on the engine. Almost always Postgres saturation, not the engine itself. Check
pg_stat_activityfor long-running queries and look at the Postgres logs.
Next steps
- Add a second RamNode VPS dedicated to workers, and point them at
engine.example.com:443. Hatchet was designed for horizontal worker scaling. - Tune PostgreSQL: bump
shared_buffersandwork_membased on RAM, and consider PgBouncer if your worker count gets into the dozens. - Replace
setup-config's self-signed internal certificate generation with cert files you manage if you want stricter compliance posture. - Wire up Prometheus by exposing the metrics endpoint that the engine and API publish and scraping them from a separate monitoring host.
Hatchet on a RamNode VPS is one of the cleanest paths to durable workflows without committing to Temporal-grade infrastructure. With the engine and API behind nginx, the database and broker isolated on the internal Docker network, and nightly encrypted backups, this deployment will carry production workloads for a long time.
