Skip to main content
Connect a deployed task to a resource that lives only inside a private GCP VPC: a Cloud SQL Postgres, a private ClickHouse, or anything with no public IP. A deployed task runs on a managed worker that sits outside your VPC, so by default it has no route in. You give it one by baking a userspace VPN client into the deployed image with a build extension, bringing the tunnel up before each run in task middleware, and routing your database client through a local SOCKS5 proxy. The whole pattern is build-extension code, runtime code, and environment variables. There are no changes to Trigger.dev itself, so you can adopt it today.

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 gcloud CLI 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:
  1. A build extension bakes the userspace VPN client into the deployed image through image.instructions. Only bytes are added to the image.
  2. 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.
  3. The client runs in userspace (no TUN device, no NET_ADMIN capability), so it works as the non-root task user, and it exposes a local SOCKS5 proxy.
  4. The task routes its database connection through that proxy using the pg client’s stream option. Traffic egresses through the tunnel to a node inside your VPC and on to the private resource.
The tunnel is brought up once per worker process and reused across runs, so you pay the setup cost only on a cold start. See Cold start below.

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.
extension/tailscaleTunnel.ts
This is a custom build extension. The 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’s runtime/tunnel.ts adds timing measurement and a checkpoint/restore health check, and wireguard/runtime/wgTunnel.ts is the WireGuard equivalent.
runtime/tunnel.ts
Wire the extension and the middleware into your config. The middleware runs before every run, and the eager ensureTunnel() call at boot overlaps the handshake with the rest of cold-start init.
trigger.config.ts
For more on middleware, see middleware and locals functions.

3. Route your database client through the tunnel

The pg 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 time run() 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.

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.
In your tailnet policy, add 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.
A successful run returns the private server details, including the private IP from 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:
On the router VM (enable IP forwarding with sudo sysctl -w net.ipv4.ip_forward=1):
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 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-1x captures essentially all of the gain.
Both tunnels survive Trigger.dev’s checkpoint and restore. A task that queried, waited long enough to be checkpointed, then queried again after restore reconnected with no re-auth. The runtime also self-heals: if the proxy is not reachable after restore it rebuilds the tunnel before continuing.

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
The build extension, the userspace daemon, the middleware, and the SOCKS routing are all unchanged. A Headscale VM plus an in-region DERP relay also shrinks the handshake round trips that dominate the Tailscale cold start.

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 via processKeepAlive), 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. processKeepAlive pushes 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.
For large or spiky fleets, self-hosting with Headscale removes all per-device and per-minute licensing, leaving only a small coordinator VM regardless of node count. Treat these bands as a model, not a quote; check Tailscale’s current pricing.

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.