Background Jobs
    Docker Compose

    Deploy Trigger.dev v4 on a VPS

    Self-host Trigger.dev v4 on RamNode — Docker Compose, GitHub OAuth, nginx TLS, bundled registry and MinIO, with Postgres and MinIO backups.

    Trigger.dev v4 is a background jobs and durable task platform built around TypeScript-first developer ergonomics. It runs your tasks in isolated containers, supports long-running waits and checkpoints, and ships with a polished web dashboard. Version 4 introduced a significantly simpler self-hosting story: no more custom startup scripts, no host networking, a bundled container registry, and bundled object storage. This guide walks through deploying Trigger.dev v4 on a RamNode VPS using the official Docker Compose stack, with TLS, a Docker socket proxy for safety, firewall hardening, and a backup strategy that covers both Postgres and the bundled MinIO object store.

    What you will build

    By the end of this guide you will have:

    • Docker Engine and Docker Compose installed on Ubuntu 24.04
    • The Trigger.dev v4 webapp stack running (Postgres, Redis, registry, MinIO, socket proxy, webapp)
    • The supervisor (worker) stack running on the same VPS or on a dedicated worker VPS
    • nginx terminating TLS in front of the webapp and the bundled Docker registry
    • GitHub OAuth as the login method (since RamNode does not allow mail services, you cannot use magic links over SMTP)
    • UFW restricting public ports to SSH, HTTP, and HTTPS
    • A working npx trigger.dev deploy against your self-hosted instance
    • Daily encrypted backups for Postgres and MinIO

    RamNode plan sizing

    Trigger.dev v4 is heavier than most self-hosted job runners because each task executes in its own container, which means Docker pull bandwidth, registry storage, and the supervisor's overhead all factor in. The official requirements are firm.

    RoleMinimum spec from upstreamSuggested RamNode plan
    Webapp (Postgres + Redis + registry + MinIO + webapp)3+ vCPU, 6+ GB RAMPremium NVMe VPS, 8 GB RAM, 4 vCPU
    Worker (supervisor + task runs)4+ vCPU, 8+ GB RAMPremium NVMe VPS, 8 to 16 GB RAM, 4 vCPU minimum
    Combined (small / staging)Sum of bothPremium NVMe VPS, 16 GB RAM, 6+ vCPU

    You can run combined on a single VPS for development or low-volume production. For anything sustained, split webapp and workers onto separate boxes and add more worker VPSes as concurrency grows. The bundled registry stores your deployed task images, so disk on the webapp host should be generous: budget at least 40 GB free for the registry on top of OS and Postgres.

    Prerequisites

    • A RamNode VPS running Ubuntu 24.04 LTS (or two, if splitting roles)
      • trigger.example.com for the webapp
      • registry.example.com for the bundled Docker registry (required so workers and your dev machine can pull task images over TLS)
    • SSH key access as a non-root sudo user
    • A GitHub OAuth app (we will configure this in step 6, since magic-link delivery requires SMTP which RamNode does not permit)
    • At least 40 GB free disk on the webapp host

    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
    
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable

    Step 2: Install Docker Engine and Docker Compose

    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. Verify both tools are installed.

    shell
    docker --version
    docker compose version

    Step 3: Clone the Trigger.dev hosting repository

    The upstream repository ships an official Compose configuration at hosting/docker. Clone a shallow checkout and switch to that path.

    shell
    sudo mkdir -p /opt/trigger
    sudo chown "$USER":"$USER" /opt/trigger
    cd /opt/trigger
    git clone --depth=1 https://github.com/triggerdotdev/trigger.dev .
    cd hosting/docker

    Inside hosting/docker you will see three subdirectories: webapp, worker, and registry. The .env.example at the top of hosting/docker is shared by both stacks.

    shell
    cp .env.example .env

    Step 4: Configure environment variables

    Open /opt/trigger/hosting/docker/.env in an editor. The variables to set right now, before first boot, are below. Anything not mentioned can stay at its default for the moment.

    shell
    # Webapp public URL
    TRIGGER_APP_ORIGIN=https://trigger.example.com
    TRIGGER_LOGIN_ORIGIN=https://trigger.example.com
    APP_ORIGIN=https://trigger.example.com
    LOGIN_ORIGIN=https://trigger.example.com
    
    # Magic link senders are disabled by default; we will use GitHub OAuth.
    # Leaving EMAIL_TRANSPORT unset means magic links log to console only,
    # which is fine for the first admin login.
    
    # Postgres credentials (change both)
    POSTGRES_USER=trigger
    POSTGRES_PASSWORD=replace_with_openssl_rand
    POSTGRES_DB=trigger
    
    # Redis password
    REDIS_PASSWORD=replace_with_openssl_rand
    
    # Secrets used by the webapp (32-byte hex strings)
    SESSION_SECRET=replace_with_openssl_rand_hex_32
    MAGIC_LINK_SECRET=replace_with_openssl_rand_hex_32
    ENCRYPTION_KEY=replace_with_openssl_rand_hex_32
    MANAGED_WORKER_SECRET=replace_with_openssl_rand_hex_32
    
    # Object storage (MinIO)
    OBJECT_STORE_BASE_URL=http://minio:9000
    OBJECT_STORE_ACCESS_KEY_ID=admin
    OBJECT_STORE_SECRET_ACCESS_KEY=replace_with_openssl_rand
    
    # Container registry (publicly reachable so workers can pull)
    DEPLOY_REGISTRY_HOST=registry.example.com
    DEPLOY_REGISTRY_NAMESPACE=trigger
    DEPLOY_REGISTRY_USERNAME=registry-user
    DEPLOY_REGISTRY_PASSWORD=replace_with_openssl_rand
    
    # Pin to the v4 GA tag (or leave 'latest')
    TRIGGER_IMAGE_TAG=v4
    
    # Disable telemetry if you prefer
    TRIGGER_TELEMETRY_DISABLED=1

    Generate the random values:

    shell
    openssl rand -base64 24   # passwords
    openssl rand -hex 32      # 32-byte hex secrets

    Generate the registry's htpasswd file before bringing the stack up, since the bundled registry reads it on startup:

    shell
    sudo apt install -y apache2-utils
    htpasswd -Bbn registry-user 'your_DEPLOY_REGISTRY_PASSWORD' \
      > /opt/trigger/hosting/docker/registry/auth.htpasswd

    Step 5: Bring up the webapp stack

    shell
    cd /opt/trigger/hosting/docker/webapp
    docker compose up -d
    docker compose ps
    docker compose logs -f webapp

    The first start will take several minutes: image pulls, Postgres init, schema migrations, and the bundled registry plus MinIO coming online. Wait for the webapp log line indicating it is serving on port 8030.

    Confirm the listening ports on the host:

    shell
    ss -tlnp | grep -E '8030|5000|9000|9001'

    You should see the webapp on 8030, the registry on 5000, and MinIO on 9000/9001, all bound to 127.0.0.1 by default in the v4 compose file. Public exposure happens through nginx in the next step.

    Step 6: GitHub OAuth (your login method)

    Trigger.dev v4 supports magic-link auth over SMTP, AWS SES, or Resend, but RamNode does not allow outbound mail traffic from its VPSes, which rules out SMTP. AWS SES or Resend would work because they go through their own provider API rather than SMTP from the VPS, but the cleanest path for a self-hosted setup is GitHub OAuth.

    1. Visit https://github.com/settings/developers and click New OAuth App.
    2. Set the homepage URL to https://trigger.example.com.
    3. Set the authorization callback URL to https://trigger.example.com/auth/github/callback.
    4. Save the Client ID and generate a Client Secret.
    5. Update .env:
    shell
    AUTH_GITHUB_CLIENT_ID=<your_client_id>
    AUTH_GITHUB_CLIENT_SECRET=<your_client_secret>
    
    # Optionally restrict signups to specific emails (regex)
    WHITELISTED_EMAILS=^(you@example\.com|teammate@example\.com)$
    
    # Promote yourself to admin on first login
    ADMIN_EMAILS=^you@example\.com$

    Apply the changes:

    shell
    cd /opt/trigger/hosting/docker/webapp
    docker compose up -d

    For the first ever login (before you have GitHub configured, or if you skip OAuth), tail the webapp logs and grab the magic link from stdout:

    shell
    docker compose logs -f webapp | grep -i 'magic link'

    Step 7: nginx and TLS

    Install nginx and certbot and open HTTP/HTTPS.

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

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

    shell
    server {
        listen 80;
        server_name trigger.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 trigger.example.com;
    
        client_max_body_size 100M;
        proxy_read_timeout   3600s;
        proxy_send_timeout   3600s;
    
        location / {
            proxy_pass         http://127.0.0.1:8030;
            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";
        }
    }

    Create /etc/nginx/sites-available/trigger-registry. The Docker registry needs large body uploads (your task images can be hundreds of megabytes) and chunked transfer support:

    shell
    server {
        listen 80;
        server_name registry.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 registry.example.com;
    
        # Required for Docker registry
        client_max_body_size           0;
        chunked_transfer_encoding      on;
        proxy_request_buffering        off;
        proxy_buffering                off;
        proxy_read_timeout             900s;
        proxy_send_timeout             900s;
    
        location /v2/ {
            # Pass auth through; the registry handles basic auth itself
            proxy_pass         http://127.0.0.1:5000;
            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;
        }
    
        # Anything else is not part of the registry API
        location / {
            return 404;
        }
    }

    Enable both, test, and issue certificates.

    shell
    sudo ln -s /etc/nginx/sites-available/trigger           /etc/nginx/sites-enabled/trigger
    sudo ln -s /etc/nginx/sites-available/trigger-registry  /etc/nginx/sites-enabled/trigger-registry
    sudo nginx -t
    sudo systemctl reload nginx
    
    sudo certbot --nginx \
      -d trigger.example.com \
      -d registry.example.com \
      --non-interactive --agree-tos -m you@example.com --redirect
    sudo certbot renew --dry-run

    Visit https://trigger.example.com, sign in via GitHub, and confirm you land on the dashboard.

    Step 8: Bring up the worker stack

    The worker (supervisor) talks to the webapp over the network and pulls deployed images from the registry. On the same VPS, that internal traffic goes over Docker networking. On a separate VPS, it goes over the public TLS endpoints you just stood up.

    If you are running combined on this VPS:

    shell
    cd /opt/trigger/hosting/docker
    docker compose -f webapp/docker-compose.yml -f worker/docker-compose.yml up -d

    If you are running the worker on a separate VPS, repeat steps 1 and 2 on that box, then:

    shell
    sudo mkdir -p /opt/trigger && sudo chown "$USER":"$USER" /opt/trigger && cd /opt/trigger
    git clone --depth=1 https://github.com/triggerdotdev/trigger.dev .
    cd hosting/docker
    cp .env.example .env

    On the worker, the only variables in .env that matter are the worker-specific ones. Set them to point at the webapp host's public endpoints:

    shell
    TRIGGER_API_URL=https://trigger.example.com
    TRIGGER_WORKER_TOKEN=<from_webapp_logs_below>
    DEPLOY_REGISTRY_HOST=registry.example.com
    DEPLOY_REGISTRY_NAMESPACE=trigger
    DEPLOY_REGISTRY_USERNAME=registry-user
    DEPLOY_REGISTRY_PASSWORD=<same_password_as_webapp_env>

    On the webapp host, tail the webapp logs for the bootstrap worker token. It is printed once on the first start:

    shell
    cd /opt/trigger/hosting/docker/webapp
    docker compose logs webapp | grep -A 12 'Worker Token'

    Set that token as TRIGGER_WORKER_TOKEN in the worker .env, then on the worker host:

    shell
    cd /opt/trigger/hosting/docker/worker
    docker login -u registry-user registry.example.com   # paste the registry password
    docker compose up -d
    docker compose logs -f supervisor

    The supervisor should connect to the webapp and register itself as the bootstrap worker group.

    Step 9: Initialize a project and deploy your first task

    From your local development machine (not the VPS), install Node.js and the Trigger.dev CLI, then log in to your self-hosted instance.

    shell
    mkdir my-trigger-app && cd my-trigger-app
    npx trigger.dev@latest login -a https://trigger.example.com --profile self-hosted
    npx trigger.dev@latest init -p <project_ref_from_dashboard> --profile self-hosted

    Create a hello-world task in trigger/hello.ts:

    shell
    import { task } from "@trigger.dev/sdk/v3";
    
    export const helloWorld = task({
      id: "hello-world",
      run: async (payload: { name: string }) => {
        return { greeting: `Hello, ${payload.name}` };
      },
    });

    Log in to the registry from your dev machine, then deploy:

    shell
    docker login -u registry-user registry.example.com
    npx trigger.dev@latest deploy --profile self-hosted

    The CLI builds the task image locally, pushes it to registry.example.com, and registers the new version with the webapp. Trigger a test run from the dashboard's Test tab and watch it execute through the supervisor.

    Step 10: Backups

    Two things need backing up: Postgres (project metadata, runs, schedules, etc.) and MinIO (task payloads and outputs over the inline threshold). The registry can also be backed up, but in practice you can rebuild it by redeploying tasks.

    Postgres

    Create /usr/local/bin/backup-trigger-pg.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/var/backups/trigger"
    RETENTION_DAYS=14
    DATE=$(date -u +%Y%m%d-%H%M%S)
    mkdir -p "$BACKUP_DIR"
    
    docker compose -f /opt/trigger/hosting/docker/webapp/docker-compose.yml exec -T postgres \
      pg_dump -U trigger -d trigger --format=custom \
      | gzip > "$BACKUP_DIR/trigger-pg-$DATE.dump.gz"
    
    find "$BACKUP_DIR" -type f -name 'trigger-pg-*.dump.gz' -mtime +"$RETENTION_DAYS" -delete

    MinIO

    Install the MinIO client on the webapp host:

    shell
    curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc
    sudo chmod +x /usr/local/bin/mc
    mc alias set local http://127.0.0.1:9000 admin '<your_OBJECT_STORE_SECRET_ACCESS_KEY>'

    Create /usr/local/bin/backup-trigger-minio.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/var/backups/trigger/minio"
    RETENTION_DAYS=14
    DATE=$(date -u +%Y%m%d-%H%M%S)
    TARGET="$BACKUP_DIR/$DATE"
    mkdir -p "$TARGET"
    
    mc mirror --overwrite local/packets "$TARGET/packets"
    tar -C "$BACKUP_DIR" -czf "$BACKUP_DIR/minio-$DATE.tar.gz" "$DATE"
    rm -rf "$TARGET"
    
    find "$BACKUP_DIR" -type f -name 'minio-*.tar.gz' -mtime +"$RETENTION_DAYS" -delete

    Wire both into a single nightly systemd timer:

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

    shell
    [Unit]
    Description=Trigger.dev backup (Postgres and MinIO)
    After=docker.service
    
    [Service]
    Type=oneshot
    ExecStart=/usr/local/bin/backup-trigger-pg.sh
    ExecStart=/usr/local/bin/backup-trigger-minio.sh

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

    shell
    [Unit]
    Description=Daily Trigger.dev backup
    
    [Timer]
    OnCalendar=daily
    RandomizedDelaySec=30m
    Persistent=true
    
    [Install]
    WantedBy=timers.target
    shell
    sudo chmod +x /usr/local/bin/backup-trigger-pg.sh /usr/local/bin/backup-trigger-minio.sh
    sudo systemctl daemon-reload
    sudo systemctl enable --now trigger-backup.timer

    For offsite copies, push /var/backups/trigger to an S3-compatible remote with rclone from the same script.

    Step 11: Hardening and ongoing operations

    • Pin the image tag. latest is convenient but unpredictable. Set TRIGGER_IMAGE_TAG to a specific GA release once you are happy with the deployment, and update deliberately.
    • Lock down the registry. The bundled registry uses HTTP basic auth via htpasswd. Rotate the password by regenerating the htpasswd file and updating DEPLOY_REGISTRY_PASSWORD, then restart the webapp stack and re-docker login from every worker and dev machine.
    • Don't expose Postgres, Redis, or MinIO. The v4 compose file binds them to 127.0.0.1 already. Keep it that way.
    • Watch disk on the registry volume. Each task deploy creates new layers. Configure registry garbage collection on a schedule, or prune old versions from the dashboard once you settle into a release cadence.
    • Use ClickHouse for events. PostgreSQL stores task events (timeline, logs, spans) by default. For production, point new events at ClickHouse by setting EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2 on the webapp. The compose file includes a ClickHouse service you can enable; otherwise PostgreSQL's TaskEvent table will grow without bound under sustained load.
    • Scale workers, not the webapp. When concurrency climbs, add more worker VPSes pointing at the same webapp. Each worker registers itself with the bootstrap group (or any group you create).

    Troubleshooting

    • docker login to the registry fails with x509 error. The certbot certificate is not yet active, or the registry server name does not match the CN on the cert. Confirm curl -I https://registry.example.com/v2/ returns 401 (not a TLS error). 401 with WWW-Authenticate: Basic realm="Registry Realm" means TLS is fine and basic auth is asking for credentials.
    • Failed to start deployment: Connection error. Almost always a network issue between the worker and the webapp, or between your dev machine and the registry. Confirm the worker can reach https://trigger.example.com/v2/... and the dev machine can docker login registry.example.com. The upstream issue tracker has examples of host.docker.internal confusion in container-from-container deploys.
    • Magic link logs but does not arrive. Expected, since RamNode does not allow mail. Use GitHub OAuth or configure Resend's API (not SMTP) if you need real email.
    • schema "graphile_worker" does not exist on first start. The webapp's startup migrations failed, usually due to a Postgres connectivity or cert issue. Tail docker compose logs webapp for the underlying error. With the in-stack Postgres in this guide, this is almost always a wrong password in .env versus the volume that Postgres was first initialized with. The cleanest fix during initial setup is docker compose down -v followed by docker compose up -d, but only do that before you have any real data.
    • Deploy succeeds but the task never runs. Check that at least one worker shows connected in the dashboard's Workers tab. If not, the supervisor cannot reach the webapp. On the worker host, docker compose logs supervisor will show the connection attempt.

    Next steps

    • Once you have steady state, split webapp and workers onto separate RamNode VPSes. Add additional workers as concurrency grows. Each worker is just another box running docker compose up -d against the same .env template.
    • Enable ClickHouse for task events under sustained load.
    • Wire up offsite backups with rclone to Backblaze B2 or Wasabi.
    • Restrict the dashboard to your team's IPs at the nginx layer if appropriate.
    • Set up monitoring on the webapp's /healthcheck endpoint and on container restart counts in Docker.

    Trigger.dev v4's self-hosting story is the most ergonomic it has ever been, but the platform is still doing a lot: a task scheduler, a container registry, an object store, and a coordinator for distributed execution. Sized properly and put behind nginx with TLS, it is a credible alternative to running tasks on a hosted platform, and a RamNode NVMe VPS gives you the disk and network performance the registry needs to keep deploys snappy.