View the example repo on GitHub
The full source for both tunnels, local Docker proofs (no cloud account needed), and GCP provisioning scripts. This guide walks through the key pieces; clone the repo for the complete, copy-paste-ready files.
Prerequisites
- An existing project with Trigger.dev initialized
- A GCP project with the
gcloudCLI authenticated - A private resource to reach, or the repo’s provisioning scripts to stand one up
- For Tailscale, a tailnet. For WireGuard, a VM with a public IP where you can open a UDP port
How it works
The mechanism is the same for both tunnels covered below:- A build extension bakes the userspace VPN client into the deployed image through
image.instructions. Only bytes are added to the image. - A global task middleware brings the tunnel up before every run. It is started eagerly at worker boot so the handshake overlaps cold-start init.
- The client runs in userspace (no TUN device, no
NET_ADMINcapability), so it works as the non-root task user, and it exposes a local SOCKS5 proxy. - The task routes its database connection through that proxy using the
pgclient’sstreamoption. Traffic egresses through the tunnel to a node inside your VPC and on to the private resource.
Choose a tunnel
Two userspace clients fit this pattern. Pick one before you start.
Use WireGuard when you control a stable endpoint in the VPC and want the fastest cold start with no vendor limits. Use Tailscale when you want ACLs, managed key rotation, NAT traversal, and a managed control plane, and can accept the roughly one-second cold-start handshake (or self-host the control plane to shrink it).
WireGuard has no coordination server, so at least one side needs a reachable endpoint. In this setup the GCP server VM has a public IP with UDP 51820 open, and the task dials outbound to it, which egress NAT allows. That port is low risk: WireGuard silently drops any packet not from an authenticated peer, so it is effectively invisible to scanners.
1. Add the build extension
The build extension downloads the userspace client and copies it into/usr/local/bin in the deployed image. The auth key is never baked in; you provide it at runtime as an environment variable so it stays encrypted and out of the image.
- Tailscale
- WireGuard
extension/tailscaleTunnel.ts
onBuildComplete hook returns early for the dev target because the tunnel is only needed in the deployed image.
2. Bring the tunnel up before each run
The runtime bootstrap starts the client in userspace networking mode, authenticates it, and resolves once the tailnet is up and the SOCKS5 proxy is listening. It is idempotent: a warm worker starts the client once and reuses it across runs. The snippet below is complete and runnable; the repo’sruntime/tunnel.ts adds timing measurement and a checkpoint/restore health check, and wireguard/runtime/wgTunnel.ts is the WireGuard equivalent.
runtime/tunnel.ts
ensureTunnel() call at boot overlaps the handshake with the rest of cold-start init.
trigger.config.ts
3. Route your database client through the tunnel
Thepg client accepts a pre-opened socket through its stream option. Hand it the socket from tunnelSocket() and every query travels over the tunnel.
runtime/db.ts
4. Write the task
The task calls the query helper like any other function. The tunnel is already up by the timerun() executes, because the middleware awaited it.
/trigger/queryPrivateGcp.ts
5. Set the environment variables
Set these on your Trigger.dev project through the dashboard or the API. Never commit them. See environment variables.- Tailscale
- WireGuard
6. Provision the private resource
The example repo includes scripts that stand up a GCP VM running Postgres, joined to the tunnel and with default-deny ingress so the database is never reachable from the public internet.- Tailscale
- WireGuard
tagOwners for tag:trigger and the resource’s tag, plus a grant from tag:trigger to the resource on tcp:5432 (and 8123 for ClickHouse). Mint one auth key for the resource and one tagged tag:trigger for the tasks.7. Deploy and trigger
Deploy the task and trigger it from the dashboard or the API.inet_server_addr(), confirming the query reached the database over the tunnel rather than any public path:
Reaching managed Cloud SQL via a subnet router
A managed service like Cloud SQL, Memorystore, or a private ClickHouse has only a private IP inside your VPC and cannot join the tunnel directly. Put a subnet router on the tunnel instead: a node that is both on the tunnel and inside the VPC, and forwards tunnel traffic to the resource’s private IP. The task dials that private IP through the tunnel. Create the Cloud SQL instance with a private IP (Private Service Access), note its private IP, then configure the subnet router:- Tailscale
- WireGuard
On the router VM (enable IP forwarding with Then in the Tailscale admin, approve the route for that machine, and add an ACL grant for the subnet destination. This grant is the step people forget: a grant to the router’s own tag does not cover the IPs behind it, so the destination IP must be allowed explicitly.Set
sudo sysctl -w net.ipv4.ip_forward=1):PGHOST=CLOUDSQL_IP on the project.Cold start
The tunnel is brought up once per worker process and reused across runs, so you pay setup only on a cold start.processKeepAlive: true keeps the process, and therefore the tunnel, alive between runs. Numbers below are from a deployed managed-worker project with the GCP resource in us-east4 and workers in AWS us-east-1:
The gap is the handshake. Tailscale registers a fresh node with its coordination server and negotiates connectivity on every cold node, roughly one second of round trips. Static WireGuard does a single handshake to a known endpoint with no coordination server, so bring-up is about 100ms.
What moves the number:
- Region proximity cuts query latency (moving the resource into the workers’ metro took it from ~500ms to ~120ms). It does not change the Tailscale handshake, which talks to the control plane, not the database.
- Eager start at worker boot overlaps the handshake with the rest of cold-start init, saving around 150ms.
- Machine size only speeds up the client spawn; the Tailscale handshake is network bound and flat across sizes.
medium-1xcaptures essentially all of the gain.
Self-hosting the control plane with Headscale
Headscale is an open-source, self-hostable implementation of the Tailscale coordination server. Running it yourself removes every Tailscale SaaS limit (device caps, ephemeral-minute metering, per-seat billing) and puts the control plane in your region. Switching to it is pure environment config, with no code change, because the runtime already forwards a login server:.env
Pricing
Only the Tailscale path has a vendor cost; static WireGuard and Headscale do not. Each live worker process is one node (reused across runs viaprocessKeepAlive), so node count is roughly your peak concurrent workers, not your total runs. Node lifetime then picks the cost bucket:
- Steady load (nodes live 4 hours or more) is billed as tagged devices. 50 tagged devices are included on all plans, then roughly $1 per device per month.
processKeepAlivepushes you here. - Spiky load (nodes die within 4 hours) is billed as ephemeral node-minutes, with a smaller included allowance that churn can exhaust quickly.
What this proves
On a real Trigger.dev Cloud project (managed workers in AWS, GCP resource in us-east4):- Both tunnels bake into the deployed image via the build extension and come up in the middleware before
run(). - The deployed task reaches both a VM Postgres and a private-IP-only managed Cloud SQL instance over both tunnels; the query returns the private server IP.
- WireGuard cold bring-up was about 100ms, Tailscale about 1.2s, warm runs about 170ms.
- Both survive checkpoint and restore (tested past five minutes) with no re-auth.
Next steps
Custom build extensions
Learn how build extensions hook into the deploy build and modify the container image.
Middleware and locals
Run setup logic before and after every task run.
Environment variables
Set the encrypted secrets this pattern relies on.
The example repo
Complete source, local Docker proofs, and provisioning scripts.

