Workflows
    Docker Compose

    Deploy Hatchet on a VPS

    Self-host the Hatchet background task and workflow platform on a RamNode VPS with Docker Compose, nginx TLS, UFW, and encrypted PostgreSQL backups.

    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.

    OptionWhen to useWhat runs
    Hatchet LiteDevelopment, internal tooling, low-throughput production workloads (single-digit workflows per second)Single bundled image plus PostgreSQL
    Full Docker ComposeProduction workloads above a few tens of workflows per second, or when you want RabbitMQ as the broker for backpressure and durabilitySeparate 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.

    WorkloadSuggested RamNode planNotes
    Hatchet Lite, internal toolsPremium NVMe VPS, 4 GB RAM, 2 vCPUOne bundled container plus Postgres, comfortable
    Full stack, low-volume productionPremium NVMe VPS, 8 GB RAM, 4 vCPUHeadroom for Postgres connections, RabbitMQ queues, and a couple of local workers
    Full stack, sustained throughputPremium NVMe VPS, 16 GB RAM, 4 to 8 vCPUWorkers 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

    shell
    sudo apt update && sudo apt upgrade -y
    sudo timedatectl set-timezone UTC
    sudo apt install -y unattended-upgrades
    sudo dpkg-reconfigure -plow unattended-upgrades

    Restrict the firewall to SSH for now.

    shell
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable
    sudo ufw status verbose

    Step 2: Install Docker Engine and Docker Compose

    Add the official Docker repository and install the engine plus the Compose plugin.

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

    shell
    docker --version
    docker compose version

    Step 3: Lay out the Hatchet project

    Create a working directory that will hold the Compose file, secrets, and persistent data.

    shell
    sudo mkdir -p /opt/hatchet
    sudo chown "$USER":"$USER" /opt/hatchet
    cd /opt/hatchet

    Generate strong random passwords for PostgreSQL and RabbitMQ before writing the compose file.

    shell
    openssl rand -base64 24
    openssl rand -base64 24

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

    shell
    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:

    shell
    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/.env

    Step 5: Bring up the stack

    shell
    cd /opt/hatchet
    docker compose up -d
    docker compose ps
    docker compose logs -f --tail=50 hatchet-api

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

    shell
    sudo apt install -y nginx certbot python3-certbot-nginx
    sudo ufw allow 'Nginx Full'
    sudo ufw status

    Create /etc/nginx/sites-available/hatchet:

    shell
    # 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:

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

    shell
    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 nginx

    Issue certificates for both hostnames in one shot:

    shell
    sudo certbot --nginx \
      -d hatchet.example.com \
      -d engine.example.com \
      --non-interactive --agree-tos -m you@example.com --redirect
    sudo certbot renew --dry-run

    Step 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:

    shell
    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-dotenv

    Create worker.py:

    shell
    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:

    shell
    HATCHET_CLIENT_TOKEN=<paste_api_token_from_dashboard>
    HATCHET_CLIENT_TLS_STRATEGY=tls

    Run it:

    shell
    python worker.py

    You 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:

    shell
    #!/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" -delete

    Make it executable and schedule via systemd timer (same pattern as the Manticore guide):

    shell
    sudo chmod +x /usr/local/bin/backup-hatchet.sh

    /etc/systemd/system/hatchet-backup.service:

    shell
    [Unit]
    Description=Hatchet PostgreSQL backup
    After=docker.service
    
    [Service]
    Type=oneshot
    ExecStart=/usr/local/bin/backup-hatchet.sh

    /etc/systemd/system/hatchet-backup.timer:

    shell
    [Unit]
    Description=Daily Hatchet backup
    
    [Timer]
    OnCalendar=daily
    RandomizedDelaySec=30m
    Persistent=true
    
    [Install]
    WantedBy=timers.target
    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now hatchet-backup.timer

    For 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/volumes and 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/deny block to the nginx server for hatchet.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. The migration service handles schema changes idempotently.

    Troubleshooting

    • Workers fail to connect with unavailable: ssl handshake. Confirm HATCHET_CLIENT_TLS_STRATEGY=tls is set, and that nginx is fronting the engine with grpc_pass, not proxy_pass. Test with grpcurl engine.example.com:443 list.
    • Dashboard returns 502. The frontend container is unhealthy or the API is not yet ready. docker compose logs hatchet-api should show database connection success.
    • pq: SSL is required in API logs. Either set sslmode=disable in DATABASE_URL for an internal Postgres, or run an external Postgres with the appropriate SSL cert mounted into the API container. The compose file above uses sslmode=disable since Postgres is on the internal Docker network.
    • High CPU on the engine. Almost always Postgres saturation, not the engine itself. Check pg_stat_activity for 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_buffers and work_mem based 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.