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 deployagainst 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.
| Role | Minimum spec from upstream | Suggested RamNode plan |
|---|---|---|
| Webapp (Postgres + Redis + registry + MinIO + webapp) | 3+ vCPU, 6+ GB RAM | Premium NVMe VPS, 8 GB RAM, 4 vCPU |
| Worker (supervisor + task runs) | 4+ vCPU, 8+ GB RAM | Premium NVMe VPS, 8 to 16 GB RAM, 4 vCPU minimum |
| Combined (small / staging) | Sum of both | Premium 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.comfor the webappregistry.example.comfor 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
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 enableStep 2: Install Docker Engine and Docker Compose
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.
docker --version
docker compose versionStep 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.
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/dockerInside 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.
cp .env.example .envStep 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.
# 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=1Generate the random values:
openssl rand -base64 24 # passwords
openssl rand -hex 32 # 32-byte hex secretsGenerate the registry's htpasswd file before bringing the stack up, since the bundled registry reads it on startup:
sudo apt install -y apache2-utils
htpasswd -Bbn registry-user 'your_DEPLOY_REGISTRY_PASSWORD' \
> /opt/trigger/hosting/docker/registry/auth.htpasswdStep 5: Bring up the webapp stack
cd /opt/trigger/hosting/docker/webapp
docker compose up -d
docker compose ps
docker compose logs -f webappThe 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:
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.
- Visit https://github.com/settings/developers and click New OAuth App.
- Set the homepage URL to
https://trigger.example.com. - Set the authorization callback URL to
https://trigger.example.com/auth/github/callback. - Save the Client ID and generate a Client Secret.
- Update
.env:
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:
cd /opt/trigger/hosting/docker/webapp
docker compose up -dFor 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:
docker compose logs -f webapp | grep -i 'magic link'Step 7: nginx and TLS
Install nginx and certbot and open HTTP/HTTPS.
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full'Create /etc/nginx/sites-available/trigger:
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:
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.
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-runVisit 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:
cd /opt/trigger/hosting/docker
docker compose -f webapp/docker-compose.yml -f worker/docker-compose.yml up -dIf you are running the worker on a separate VPS, repeat steps 1 and 2 on that box, then:
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 .envOn 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:
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:
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:
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 supervisorThe 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.
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-hostedCreate a hello-world task in trigger/hello.ts:
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:
docker login -u registry-user registry.example.com
npx trigger.dev@latest deploy --profile self-hostedThe 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:
#!/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" -deleteMinIO
Install the MinIO client on the webapp host:
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:
#!/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" -deleteWire both into a single nightly systemd timer:
/etc/systemd/system/trigger-backup.service:
[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:
[Unit]
Description=Daily Trigger.dev backup
[Timer]
OnCalendar=daily
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.targetsudo 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.timerFor 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.
latestis convenient but unpredictable. SetTRIGGER_IMAGE_TAGto 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 updatingDEPLOY_REGISTRY_PASSWORD, then restart the webapp stack and re-docker loginfrom 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_v2on the webapp. The compose file includes a ClickHouse service you can enable; otherwise PostgreSQL'sTaskEventtable 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 loginto 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. Confirmcurl -I https://registry.example.com/v2/returns 401 (not a TLS error). 401 withWWW-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 reachhttps://trigger.example.com/v2/...and the dev machine candocker login registry.example.com. The upstream issue tracker has examples ofhost.docker.internalconfusion 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 existon first start. The webapp's startup migrations failed, usually due to a Postgres connectivity or cert issue. Taildocker compose logs webappfor the underlying error. With the in-stack Postgres in this guide, this is almost always a wrong password in.envversus the volume that Postgres was first initialized with. The cleanest fix during initial setup isdocker compose down -vfollowed bydocker 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
connectedin the dashboard's Workers tab. If not, the supervisor cannot reach the webapp. On the worker host,docker compose logs supervisorwill 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 -dagainst the same.envtemplate. - Enable ClickHouse for task events under sustained load.
- Wire up offsite backups with
rcloneto 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
/healthcheckendpoint 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.
