A deployed Trigger.dev task (managed worker) can connect to a resource that only lives inside a private GCP VPC (a Cloud SQL Postgres, a private ClickHouse, anything with no public IP), using only a build extension, runtime code, and environment variables. There are no changes to Trigger.dev itself, so it is a pattern you can adopt today.
Two tunnels are shown, sharing the same shape (bundle a userspace client, bring it up before the task runs, route DB clients through a local SOCKS5 proxy):
- Tailscale — mesh VPN with a coordination server, ACLs, and NAT traversal.
- WireGuard (
wireproxy) — a static point-to-point tunnel, no coordination server, much faster cold start.
Both were verified end to end on Trigger.dev Cloud against a private-IP-only managed Cloud SQL instance.
| Tailscale | WireGuard | |
|---|---|---|
| Reaches VM Postgres | yes | yes |
| Reaches managed Cloud SQL (private IP, via subnet router) | yes | yes |
| Cold tunnel bring-up | ~1.2s | ~0.1s |
| Warm run | ~0.17s | ~0.17s |
| Query latency (GCP + AWS in same metro) | ~0.12s | ~0.03–0.12s |
| Access control | tailnet ACLs (tags/grants) | none built-in (raw WireGuard) |
| NAT traversal / dynamic peers | yes | no (needs a reachable endpoint) |
| Key management | auth keys, rotation, admin UI | you manage static keys |
| Vendor cost | SaaS device/minute limits (or self-host Headscale) | none |
Rule of thumb: WireGuard when you control a stable endpoint in the VPC and want the fastest cold start with no vendor limits. Tailscale when you want ACLs, easy key management, NAT traversal, and a managed control plane (and can wear the ~1s cold-start handshake, or self-host Headscale to shrink it).
Plain WireGuard has no coordination server, hole-punching, or relay: a peer only
sends packets to the Endpoint (IP:port) you configure. So at least one side must
have a reachable endpoint — a public IP:port or a port-forward. It can't connect
two peers that are both behind NAT with no dialable address. Tailscale does all of
that (STUN-style hole-punching, DERP relay fallback, its coordination server
brokering the path), so both peers can be fully private with no exposed port.
Why it doesn't hurt this setup: the GCP side is given a reachable endpoint — the
WireGuard server VM has a public IP with UDP 51820 opened. The task, behind AWS
egress NAT, simply dials outbound to it, which NAT allows, and
PersistentKeepalive holds the mapping open. One reachable endpoint plus an
outbound dial is enough; no traversal needed.
When it would matter:
- You won't expose any inbound endpoint on the GCP side (no public IP, no open port). Tailscale still works via DERP relay; plain WireGuard can't be dialed.
- The GCP endpoint's IP is dynamic or roams — WireGuard's static
Endpointbreaks; Tailscale re-discovers it.
Exposing UDP 51820 is low-risk: WireGuard silently drops any packet not from an authenticated peer and never replies to unauthenticated ones, so the port is effectively invisible to scanners. It's a requirement (you must own a reachable endpoint), not really an attack surface — that's the real tradeoff behind the "no".
- A build extension bakes the userspace client (
tailscale/tailscaled, orwireproxy) into the deployed image viaimage.instructions. Only bytes are added to the image. - A global
tasks.middlewarebrings the tunnel up before every run (started eagerly at worker boot so the handshake overlaps cold-start init). - The client runs in userspace (no TUN device, no
NET_ADMIN), so it works as the non-root task user, and exposes a local SOCKS5 proxy. - The task routes its DB connection through that proxy:
pgvia itsstreamoption. Traffic egresses through the tunnel to a node inside the VPC and on to the private resource.
The task's userspace tailscaled registers with Tailscale's control plane on a
cold start (the ~1.2s handshake), then carries data over WireGuard to a node in the
VPC, directly or relayed via DERP.
flowchart LR
subgraph AWS["AWS - Trigger.dev managed worker"]
task["task run()"]
tsd["userspace tailscaled<br/>SOCKS5 :1055"]
task -->|"pg via stream"| tsd
end
coord["Tailscale control plane<br/>coordination + DERP"]
subgraph GCP["Customer GCP VPC"]
router["Tailscale node /<br/>subnet router"]
db[("private Postgres /<br/>Cloud SQL")]
router --> db
end
tsd -.->|"register + netmap<br/>cold handshake ~1.2s"| coord
tsd ==>|"WireGuard data<br/>direct or via DERP"| router
wireproxy does a single static handshake straight to the WireGuard server's public
UDP endpoint (an outbound dial, ~0.1s), with no control plane in the path.
flowchart LR
subgraph AWS["AWS - Trigger.dev managed worker"]
task["task run()"]
wp["wireproxy<br/>userspace WireGuard + SOCKS5 :25344"]
task -->|"pg via stream"| wp
end
subgraph GCP["Customer GCP VPC"]
wgs["WireGuard server VM<br/>public UDP :51820"]
db[("private Postgres /<br/>Cloud SQL")]
wgs --> db
end
wp ==>|"static WG handshake<br/>outbound dial ~0.1s"| wgs
For a managed resource (Cloud SQL, private ClickHouse) the tunnel node acts as a subnet router, bridging the tunnel to the resource's private IP over VPC peering.
flowchart LR
t(["tunnel from task"]) --> r["subnet-router VM<br/>in the VPC"]
r ==>|"VPC peering"| cs[("managed Cloud SQL<br/>private IP only")]
r -.->|"WG: forward + masquerade<br/>Tailscale: advertise route + ACL grant"| cs
sequenceDiagram
participant D as deploy
participant B as build extension
participant W as deployed worker
participant T as tunnel client
participant DB as private DB
D->>B: trigger.dev deploy
B->>B: bake tunnel binary into image
Note over W: cold start
W->>T: middleware brings tunnel up (before run)
T-->>W: SOCKS5 ready
W->>DB: pg query via SOCKS5 over the tunnel
DB-->>W: rows
extension/tailscaleTunnel.ts Tailscale build extension
runtime/tunnel.ts Tailscale userspace bootstrap + pg/http helpers + timing/health
runtime/db.ts pgSelect() over the tunnel
src/queryPrivateGcp.ts Tailscale task
src/queryPrivateGcpWg.ts WireGuard task
src/baselineColdStart.ts No-tunnel baseline (for cold-start measurement)
src/checkpointRestore.ts Proves the tunnel survives checkpoint/restore
trigger.config.ts Wires both extensions + a tunnel middleware
wireguard/extension/… WireGuard build extension (bakes wireproxy)
wireguard/runtime/wgTunnel.ts WireGuard userspace bootstrap
wireguard/proofs/ Local WireGuard proof (no cloud needed)
proofs/ Local Tailscale proofs (no cloud needed)
gcp/provision.sh A tailnet-joined Postgres VM (Tailscale)
gcp/provision-wireguard.sh A WireGuard-server Postgres VM
docs/managed-cloud-sql.md Reaching managed Cloud SQL via a subnet router
docs/cold-start.md Measurements and tuning
docs/headscale.md Self-hosted control plane (removes Tailscale limits)
docs/pricing.md Tailscale pricing model
Common: install deps (npm install), set project in trigger.config.ts, set the
env vars for whichever tunnel you use (see .env.example), then
npx trigger.dev@latest deploy. Trigger the task from the dashboard or the API.
- In your tailnet policy add
tagOwnersfortag:triggerandtag:gcp-db, plus a granttag:trigger -> tag:gcp-dbontcp:5432(and8123for ClickHouse). Seegcp/tailscale-acl.jsonc. - Mint two ephemeral + reusable + tagged auth keys (
tag:gcp-dbfor the resource,tag:triggerfor the tasks ->TS_AUTHKEY). - Stand up the private resource:
gcp/provision.sh(a VM that joins the tailnet), or a subnet router for existing infra (seedocs/managed-cloud-sql.md). - Set
TS_AUTHKEYand thePG*vars, deploy, triggerquery-private-gcp.
- Generate a client keypair (
wg genkey | tee priv | wg pubkey). - Stand up the server:
gcp/provision-wireguard.sh(pass the client public key), then read back the server public key. - Set
WG_PRIVATE_KEY,WG_SERVER_PUBLIC_KEY,WG_ENDPOINT,WG_ALLOWED_IPSand thePG*vars, deploy, triggerquery-private-gcp-wg.
Point the tunnel node at the managed resource's private IP as a subnet router. Full steps for both tunnels (including the Tailscale route-approval and the ACL grant for the subnet destination) are in docs/managed-cloud-sql.md.
You pay tunnel setup only on a cold worker process; warm runs reuse it. WireGuard is ~0.1s; Tailscale is ~1.2s (its coordination-server handshake, which is network-bound and not helped by bigger machines). Region proximity cuts query latency (~500ms -> ~120ms), and self-hosting Headscale can shrink the Tailscale handshake. Details and how to reproduce: docs/cold-start.md.
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 a VM Postgres and a private-IP-only managed Cloud SQL instance (the query returns the private server IP), over both tunnels.
- WireGuard cold bring-up ~100ms; Tailscale ~1.2s; warm ~170ms.
- Both survive checkpoint/restore (tested to 5+ minutes) with no re-auth.
proofs/ (Tailscale) and wireguard/proofs/ (WireGuard) prove the mechanics with
Docker, no cloud account: the client bakes into the real base image, starts as a
non-root user with all capabilities dropped, and reaches a Postgres on an isolated
network only through the tunnel.
MIT. See LICENSE.