Using your own PostgreSQL from Cloudflare Workers: connect without opening a port, and share one server between services
Full-text and vector search led us to run PostgreSQL on a VM behind a Workers site. A record of connecting it through Hyperdrive, Workers VPC and Tunnel, separating services with roles, and keeping backups and batch jobs on the VM.
You can choose to own just the database. Open no inbound port, let a Tunnel connect out from inside, and have the site read through Hyperdrive. Limit each service's role by time and connections, and several services can share one server.
Why own just the database
Owning no servers is the default for a small team (in A stack for solo developers and early startups, even the database was D1). One service, however, needed three things at once.
- Japanese full-text search: find titles and names by substring, quickly, even for one- or two-character words
- Vector search: find products close in meaning to words like “I want to unwind after a tiring night”
- About 480,000 products: each with detailed JSON, updated every day from an external API
PostgreSQL lets you use PGroonga (full-text search that handles Japanese well) and pgvector (vector search) on the same table. So we kept the site on Workers and put only the database on a VM. This article records that setup and what running it taught us.
Prices, limits and beta features change. Check each company's official documentation before you use them. Workers VPC is in beta as of October 2026.
The overall path
The path from the site's Worker to PostgreSQL on the VM is:
- Hyperdrive: pools the Worker's connections into a few connections to PostgreSQL, so a request does not reconnect to a distant database every time
- Workers VPC: the entry that lets Hyperdrive reach a destination not published on the internet (
localhost:5432behind the Tunnel) - Cloudflare Tunnel:
cloudflaredon the VM opens the connection to Cloudflare from inside, so the VM needs no inbound port - PostgreSQL: listens on
localhostonly, so nobody can connect to it directly from the internet
The only way into the VM is SSH for administration. Even that is allowed by the firewall only from the address range of Google's IAP (a managed bastion), and you enter with gcloud compute ssh --tunnel-through-iap.
How this differs from the earlier connection
At first we connected with “Tunnel + Access”, as in Hyperdrive's documented procedure: the Tunnel gets a public hostname, and only a Hyperdrive holding a Cloudflare Access service token may pass. We have since moved to Workers VPC.
| Tunnel + Access | Workers VPC | |
|---|---|---|
| Public hostname | Needed (such as db.<domain>) | Not needed |
| Access application and service token | Needed | Not needed |
| Secrets given to Hyperdrive | Database password, token ID and secret | Database password |
| Status | Generally available | Beta (October 2026) |
Fewer hostnames and tokens mean fewer things to rotate and fewer places to misconfigure. We moved knowing that it is a beta.
Sharing one server between services
The VM holds one database per service. The script that adds a service creates two roles for it.
- Owner role (
<name>): creates the tables and writes the daily updates - Site role (
<name>_web): used by the site's Worker; reads, and writes only to the tables it needs
The site role gets these limits:
CREATE ROLE "<name>_web" LOGIN PASSWORD '...' CONNECTION LIMIT 20;
ALTER ROLE "<name>_web" SET statement_timeout = '3s';
REVOKE ALL ON DATABASE "<name>" FROM PUBLIC;- Cancel after 3 seconds: one service's heavy query does not drag the others down. When a query is cancelled, the site falls back to a search with fewer conditions
- At most 20 connections: Hyperdrive's pool cannot use up the database's connections
- No way into other databases: every database drops “anyone may connect” (
CONNECTforPUBLIC). A leaked password for one service does not expose another service's data
In practice, one search's SQL once picked a slow plan and took 4 seconds. Because it is cancelled at 3 seconds, only that search fails and the other services are not affected (the cause and the fix are in the UNION ALL article).
What Terraform creates, and what it does not
Terraform creates the VM and the network.
- VM: Tokyo region,
e2-standard-2(2 vCPUs, 8 GB), 50 GB disk - A dedicated network, and a firewall that allows only SSH from IAP
- Disk snapshots: daily, kept 30 days
- Budget alert: JPY 15,000 a month for the whole project (mail at 50% and 100%)
Secrets never go into Terraform. A database password or Tunnel token in a variable stays in plain text in Terraform's state file. Secrets are entered on the server, without showing them. For example, the Tunnel token is typed locally and piped over SSH into a file only root can read.
read -rs -p "Tunnel token for $NAME (not shown): " TOKEN; echo
printf 'TUNNEL_TOKEN=%s\n' "$TOKEN" | gcloud compute ssh pg-main "${Z[@]}" \
--command "sudo tee /etc/cloudflared-$NAME.env >/dev/null && sudo chmod 600 /etc/cloudflared-$NAME.env"
unset TOKENA Tunnel and its Hyperdrive must be in the same Cloudflare account as the Worker. Because sites in another account use the server too, the VM runs one cloudflared per account.
Backups and daily batch jobs
Owning the data makes backups your job.
- Disk snapshots: daily, kept 30 days (taken by GCP)
- Logical backups: every day, each database is dumped with
pg_dump, and sent with the roles to object storage (R2). Kept 14 days - Restore checks: once a month, restore into another database with
pg_restore. A backup you have never restored is as good as none
The jobs that import products from the external API run on the same VM as systemd timers: a full update every day, a sync of changes three times a day, and extra details every hour. Running right next to the database, writing hundreds of thousands of rows does not cross the network again and again.
What running it taught us
- Size the vector index to fit in memory: pgvector's HNSW index (1024 dimensions stored as halfvec, about 1.3 GB for about 480,000 rows) did not fit in the memory of a 4 GB VM. We moved to 8 GB and added a script that sets PostgreSQL's memory settings (such as
shared_buffersat a quarter of memory) for the VM's size - Develop locally in a container with the same extensions:
wrangler devconnects to a local database throughlocalConnectionStringinstead of Hyperdrive. An image with PGroonga and pgvector lets you try the same SQL as production - Keep heavy batches away from the site's searches: the daily update runs early in the morning, when traffic is low
- Know what can grow without downtime: the disk grows without stopping, but changing the VM type stops it for a few minutes
When this fits, and when it does not
| Fits | Does not fit |
|---|---|
| You need PostgreSQL extensions (full-text, vectors) | A managed database such as D1 is enough |
| The data is large and updated in daily batches | Writes are heavy and must be fast from everywhere |
| You can watch backups and updates yourself | Nobody can look after a database |
Owning the database brings OS updates, free disk space and backup checks back to you. The site itself, though, stays on Workers with no servers to own. You own only what you need to.
Checklist
- The database port is not open to the internet (the Tunnel connects from inside)
- The site role has a statement timeout and a connection limit
- Roles cannot connect to other services' databases
- No password or token is left in Terraform's state file or in shell history
- You have actually restored a backup
- The indexes fit in memory