Cloudflare Hyperdrive tutorial: use PostgreSQL with Workers

By

Learn Cloudflare Hyperdrive with PostgreSQL and Workers, configure pooling and caching, and see how it fits with D1, R2, Queues, Supabase, and Neon.

~~~

Cloudflare Hyperdrive connects Cloudflare Workers to an existing PostgreSQL or MySQL database. It keeps database connections ready to reuse and can cache read queries, so your Worker spends less time opening connections and waiting for repeated results.

Your database stays where it is. You can keep PostgreSQL on a VPS, Supabase, Neon, or RDS and build the application on Workers.

In this tutorial we’ll build a small PostgreSQL-backed notes API, run it locally, and configure Hyperdrive for deployment. Then we’ll look at how it fits with Pages, D1, R2, Queues, Durable Objects, and AI applications, and which parts of an existing stack it can replace.

If Workers are new to you, start with my Cloudflare Workers tutorial. My free PostgreSQL course covers the database prerequisites.

What problem does Cloudflare Hyperdrive solve?

Imagine your database runs in Frankfurt. A visitor in Sydney requests a page from a Worker running nearby.

The Worker needs a database connection. Opening a TCP connection, negotiating TLS, and authenticating involve network exchanges before the SQL query can run. Across that distance, connection setup can cost more time than the query itself.

A traditional Node.js server usually keeps a pool of connections alive. Workers run in a different environment: you shouldn’t share a live database client across unrelated requests.

Hyperdrive handles the long-lived origin connections for you. Your Worker opens a nearby connection to Hyperdrive, which forwards queries through its pool near the database. Closing the client in your Worker doesn’t destroy that reusable origin pool. Cloudflare explains this connection lifecycle here.

How a Worker reaches PostgreSQL through HyperdriveA browser calls a Worker. The Worker connects to Hyperdrive, which either returns an eligible cached read or uses a pooled connection to the existing database.YesNoBrowserCloudflare WorkerHyperdriveEligible cachedread?Return cached resultReusable databaseconnectionExisting PostgreSQLdatabase

An uncached query still travels to your database. A slow SQL query still needs an index or a rewrite. Hyperdrive doesn’t turn one PostgreSQL server into a globally replicated database.

What can Hyperdrive replace in an existing stack?

Hyperdrive takes over work between your Worker and your database. Whether you can remove another service depends on what that service does for the rest of your application.

Existing componentWhat Hyperdrive can take overWhat you still need
PgBouncer or a provider’s connection poolerPooling for traffic coming from WorkersPooling for other applications, or features Hyperdrive doesn’t support
A small API server that only forwards SQL requestsWorkers plus Hyperdrive can implement those endpointsAuthentication, validation, authorization, and any business logic
Neon HTTP or WebSocket database driverAn alternative Worker-to-Postgres path using a standard driverNeon still hosts the database
Redis or KV used only to cache repeated SQL resultsSome caching of eligible, stale-tolerant readsExplicit invalidation, counters, sessions, locks, and other application data
Prisma or DrizzleThe connection path beneath a supported ORM setupThe ORM’s schema, queries, types, and migrations
Supabase, Neon, RDS, or a PostgreSQL VPSConnection setup and pooling for WorkersThe database, backups, storage, and provider-specific services
D1A different choice when you need existing PostgreSQL or MySQLA database behind Hyperdrive; it doesn’t host one

These are architectural options, not drop-in replacements for every feature in each service.

Hyperdrive vs PgBouncer

PgBouncer is a PostgreSQL connection pooler. You operate it yourself or get it through a database provider. Hyperdrive gives Workers a managed connection path with pooling and optional query caching.

If you added PgBouncer solely to handle connections from Workers, Hyperdrive may let you remove that instance. First check the PostgreSQL features your application needs. Hyperdrive uses transaction pooling and has its own compatibility limits.

If a Rails app, a Node.js server, and reporting tools also connect through PgBouncer, keep their connection path until you’ve decided how to serve those clients. Hyperdrive is configured as a Workers binding; it isn’t a general public connection endpoint for every application in your stack.

Hyperdrive vs Neon and Supabase

With Neon, you can keep the same database and change how Workers connect to it. The Neon serverless driver sends queries over HTTP or WebSockets. Hyperdrive uses supported PostgreSQL drivers through its binding. Compare both paths with your actual queries before switching.

For Hyperdrive, Cloudflare’s Neon instructions use the direct connection string with Neon’s connection pooling unchecked. Hyperdrive provides the pool on this path.

The Supabase instructions also specify a direct database connection. This bypasses Supabase’s pooler for those Worker queries. It doesn’t replace Supabase Auth, Storage, Realtime, or its generated data API.

Be especially careful when replacing Supabase API calls with SQL. A database connection does not automatically inherit the signed-in user’s JWT context. A privileged database role can bypass row-level security. Your Worker must verify the caller and enforce the intended access rules.

What a stack migration would look like

For Vercel Functions → Neon, you could move supported endpoints to Workers and use Hyperdrive with pg against the same Neon database. Neon still manages PostgreSQL. Moving the endpoints means porting their runtime assumptions, environment variables, and deployment configuration; changing a database URL alone won’t move the application.

For a VPS running Express → PgBouncer → PostgreSQL, you could move the HTTP API to Workers and replace the pooler on that path with Hyperdrive. You still operate PostgreSQL on the VPS. If Express also runs a scheduler, serves WebSockets, or processes background jobs, account for those jobs separately before shutting it down.

For Supabase Auth + database + Storage, Hyperdrive can connect your Worker directly to the database while the other Supabase services stay in place. Replacing the whole platform would be a much larger project. Start by drawing which requests use SQL and which use the provider’s other APIs.

Build a PostgreSQL API with Hyperdrive

We’ll expose published notes through two read-only endpoints:

RequestResult
GET /notesThe latest 20 published notes
GET /notes/1One published note, or a 404

There is no public write endpoint. We’ll add the sample data with SQL, which keeps authentication out of this first example without leaving an unprotected editing API online.

You need Node.js, a Cloudflare account, and PostgreSQL. A local PostgreSQL server is enough to run the code. Deployment needs a database Hyperdrive can reach, using TLS, either publicly or through the private networking setup described later.

1. Create the Worker project

Run:

npm create cloudflare@latest -- hyperdrive-notes
cd hyperdrive-notes

Choose the Hello World example, Worker only, and TypeScript. Decline deployment for now.

Install the PostgreSQL driver and its TypeScript definitions:

npm install pg
npm install -D @types/pg

Use the current pg package when following this tutorial. Cloudflare recommends it for PostgreSQL and documents the supported driver requirements in its node-postgres guide.

2. Create the database tables

In a development database named notes, save this as schema.sql:

CREATE TABLE notes (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title text NOT NULL,
  body text NOT NULL,
  published boolean NOT NULL DEFAULT false
);

CREATE INDEX notes_published_id_idx
  ON notes (id DESC)
  WHERE published = true;

INSERT INTO notes (title, body, published)
VALUES
  ('First note', 'PostgreSQL stores the notes.', true),
  ('Working draft', 'This note is private.', false);

Run it with psql against your development database:

psql -d notes -f schema.sql

Your database provider’s SQL editor works too. The partial index matches our public list query. The unpublished row gives us something to check: requesting it must return a 404.

Use a dedicated runtime database role with only the permissions the application needs. For this API that means SELECT on notes, plus the schema access needed to reach it. Run schema changes using a separate migration role.

3. Create a Hyperdrive configuration

In the Cloudflare dashboard, open Storage & databases → Hyperdrive → Create configuration.

Name it notes-db and enter your development database’s direct connection details. For a hosted database, copy the direct PostgreSQL connection string from the provider. Use the restricted runtime role.

Cloudflare checks that it can connect before creating the configuration. A PostgreSQL server bound only to your laptop’s loopback address won’t be reachable from Cloudflare.

Disable query caching for this first configuration. We want database reads to reflect the current data while learning the connection path.

You can also disable caching on an existing configuration using Wrangler:

npx wrangler hyperdrive update YOUR_HYPERDRIVE_ID --caching-disabled true

Replace YOUR_HYPERDRIVE_ID with the ID Cloudflare gives you. The ID identifies the configuration; the database password stays in the Hyperdrive configuration rather than in your Worker source.

If you haven’t authenticated Wrangler, run npx wrangler login. My Wrangler guide explains the CLI setup.

Hyperdrive requires TLS to the origin database. For private certificate authorities or explicit certificate and hostname verification, configure the appropriate SSL mode and certificates using Cloudflare’s TLS guide. Don’t solve certificate errors by disabling verification in your application.

4. Add the Hyperdrive binding

Replace the generated wrangler.jsonc with:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hyperdrive-notes",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-24",
  "compatibility_flags": ["nodejs_compat"],
  "observability": {
    "enabled": true
  },
  "hyperdrive": [
    {
      "binding": "HYPERDRIVE",
      "id": "YOUR_HYPERDRIVE_ID"
    }
  ]
}

Replace the ID with your actual configuration ID. Keep the binding name HYPERDRIVE, since we’ll use it in the code.

The compatibility date selects Workers runtime behavior. Use today’s date for a new project. The explicit Node.js compatibility flag keeps the database driver’s requirements visible; current compatibility dates enable Node.js compatibility by default. Cloudflare’s setup guide explains the date-dependent behavior.

Generate the binding types:

npx wrangler types

Wrangler generates Env, including Env.HYPERDRIVE. Run this again when you change bindings.

5. Write the complete Worker

Replace src/index.ts with:

import { Client } from 'pg'

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url)
    const detail = /^\/notes\/([1-9]\d*)$/.exec(url.pathname)

    if (url.pathname !== '/notes' && !detail) {
      return Response.json({ error: 'Not found' }, { status: 404 })
    }

    if (request.method !== 'GET') {
      return Response.json(
        { error: 'Method not allowed' },
        { status: 405, headers: { Allow: 'GET' } }
      )
    }

    const id = detail ? Number(detail[1]) : undefined

    if (id !== undefined && (!Number.isSafeInteger(id) || id > 2147483647)) {
      return Response.json({ error: 'Invalid note ID' }, { status: 400 })
    }

    const client = new Client({
      connectionString: env.HYPERDRIVE.connectionString,
      connectionTimeoutMillis: 5000,
      query_timeout: 5000
    })

    try {
      await client.connect()

      const result = id === undefined
        ? await client.query(
            'SELECT id, title FROM notes WHERE published = true ORDER BY id DESC LIMIT 20'
          )
        : await client.query(
            'SELECT id, title, body FROM notes WHERE id = $1 AND published = true',
            [id]
          )

      if (id !== undefined && result.rows.length === 0) {
        return Response.json({ error: 'Not found' }, { status: 404 })
      }

      return Response.json(id === undefined ? result.rows : result.rows[0], {
        headers: { 'Cache-Control': 'no-store' }
      })
    } catch {
      console.error({ event: 'notes_database_error' })
      return Response.json({ error: 'Database unavailable' }, { status: 503 })
    } finally {
      await client.end().catch(() => {})
    }
  }
} satisfies ExportedHandler<Env>

The detail query uses $1 with a separate values array. User input never becomes part of the SQL text. Both queries explicitly filter out unpublished notes.

An empty database returns [] from /notes. A missing or unpublished note returns a 404. Database failures return a generic 503 without exposing credentials or SQL details to the caller.

The client belongs inside the handler. Hyperdrive maintains the reusable origin pool. The explicit end() also releases the direct connection during local development; Workers automatically cleans up Worker-to-Hyperdrive connections when an invocation ends.

query_timeout limits how long this client waits. It isn’t a substitute for a PostgreSQL server-side statement_timeout when you need to bound database execution itself.

6. Run locally

In zsh, read your development PostgreSQL URL without echoing it, then pass it to Wrangler through the environment:

read -rs 'notes_db_url?Development PostgreSQL URL: '
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="$notes_db_url" npx wrangler dev
unset notes_db_url

Enter the full URL, including a username, password, host, port, and the notes database name. Wrangler’s local Hyperdrive binding requires a nonempty password in the URL, even when the local PostgreSQL server uses trust authentication. URL-encode special characters in credentials. Don’t put real credentials in wrangler.jsonc.

You can run locally before creating a remote Hyperdrive configuration: temporarily use a dummy configuration ID such as 00000000000000000000000000000000 with the local connection string. Replace it with a real ID before remote testing or deployment.

In another terminal:

curl -i http://localhost:8787/notes
curl -i http://localhost:8787/notes/1
curl -i http://localhost:8787/notes/2
curl -i -X POST http://localhost:8787/notes

You should see the published note in the list, its body in the detail response, a 404 for the private draft, and a 405 for POST.

Local development connects directly to the configured database. It doesn’t exercise Hyperdrive’s deployed connection pool or query cache. To test those, use npx wrangler dev --remote with a real configuration pointing to a development database. That mode uses the remote database even if you have a local connection string configured. See Cloudflare’s local development guide.

7. Deploy and check the remote path

Type-check and deploy:

npx tsc --noEmit
npx wrangler deploy

Wrangler prints the deployed URL. Repeat the four requests against that URL and inspect the Hyperdrive metrics in the dashboard. Keep your test database separate from production.

Before adding real users, also test what happens when the database is unreachable and when the notes table is empty. A successful deployment only proves the Worker uploaded; the requests prove that its binding, credentials, and SQL work together.

Add query caching when stale reads are acceptable

For a public notes feed, serving an older result briefly may be fine. For a permission check or the balance in an account, it usually isn’t.

Hyperdrive caches eligible reads when caching is enabled. Its documented defaults are 60 seconds of cache lifetime and a further 15 seconds of stale-while-revalidate. Writes do not invalidate cached query results. Use a cache-disabled configuration for reads that must be fresh. Cloudflare documents the cache behavior and consistency rules here.

For example, if a cached query includes a note you later unpublish, the cached result can still contain it. Keep this tutorial’s API uncached if unpublishing must immediately remove public access.

To experiment safely, create a second configuration named notes-public-cache pointing at the same development database. Enable caching there, with a shorter window:

npx wrangler hyperdrive update YOUR_CACHED_HYPERDRIVE_ID --caching-disabled false --max-age 30 --swr 0

Add it alongside HYPERDRIVE in your existing hyperdrive array:

{
  "binding": "PUBLIC_DB",
  "id": "YOUR_CACHED_HYPERDRIVE_ID"
}

Regenerate types. For a public query where that delay is acceptable, construct its client with:

const client = new Client({
  connectionString: env.PUBLIC_DB.connectionString
})

Keep security checks, editing screens, and immediate read-after-write queries on env.HYPERDRIVE. When using an ORM or authentication library, give it the client connected to the correct binding.

An HTTP Cache-Control: no-store header doesn’t disable SQL query caching. Those settings act at different layers. Avoid SQL-comment tricks to force cache misses; choose the appropriate Hyperdrive configuration.

Transactions and connection limits

Hyperdrive uses transaction pooling. A transaction holds an origin connection until it commits or rolls back. Afterward, another request can reuse that connection. Session settings aren’t something to carry between independent queries. The pooling guide explains these boundaries.

Suppose publishing a note must also create an audit record. Save this additional schema as audit.sql and apply it with your migration role:

CREATE TABLE note_audit (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  note_id integer NOT NULL REFERENCES notes(id),
  action text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

Save the following function as src/publish-note.ts. Call it only after authenticating an editor and checking their permission to publish. The database role for that authenticated write path needs the corresponding write and sequence permissions.

import { Client } from 'pg'

export async function publishNote(env: Env, id: number): Promise<void> {
  const client = new Client({
    connectionString: env.HYPERDRIVE.connectionString
  })

  try {
    await client.connect()
    await client.query('BEGIN')

    try {
      const result = await client.query(
        'UPDATE notes SET published = true WHERE id = $1 RETURNING id',
        [id]
      )

      if (result.rowCount !== 1) {
        throw new Error('Note not found')
      }

      await client.query(
        'INSERT INTO note_audit (note_id, action) VALUES ($1, $2)',
        [id, 'published']
      )
      await client.query('COMMIT')
    } catch (error) {
      await client.query('ROLLBACK')
      throw error
    }
  } finally {
    await client.end().catch(() => {})
  }
}

Both writes use the same client. If the audit insert fails, the note update rolls back too. Don’t make an external API call between BEGIN and COMMIT; it keeps a database connection occupied while you wait.

Each Hyperdrive configuration has its own connection budget. If cached and uncached configurations point to one database, include both when planning capacity. Leave room for migrations, administrative access, and other applications.

You can tune a configuration with:

npx wrangler hyperdrive update YOUR_HYPERDRIVE_ID --origin-connection-limit 10

This is a soft limit, so leave headroom rather than assigning Hyperdrive every connection your database permits. Cloudflare’s pool tuning guide covers the details.

Use Hyperdrive with Drizzle or MySQL

An ORM sits above the database driver. Hyperdrive changes the connection path beneath it.

For Drizzle with PostgreSQL, install:

npm install drizzle-orm

This complete optional src/db.ts module creates a request-scoped Drizzle client. Call it inside a handler and close client in a finally block after the queries:

import { drizzle } from 'drizzle-orm/node-postgres'
import { Client } from 'pg'

export async function connectDb(env: Env) {
  const client = new Client({
    connectionString: env.HYPERDRIVE.connectionString
  })
  await client.connect()
  return { db: drizzle(client), client }
}

Use the PostgreSQL adapter here. The D1 adapter targets a different database engine. Keep migrations in your development or release process, using the provider’s direct connection where needed. Cloudflare has a Drizzle integration guide.

For MySQL, create a separate Hyperdrive configuration targeting your MySQL database. Use mysql2, enable disableEval: true, and use its query() API rather than protocol-level prepared statements. Hyperdrive’s MySQL support has different restrictions from PostgreSQL; don’t translate the PostgreSQL sample by changing only the connection URL. Follow the MySQL driver example.

How Hyperdrive fits with other Cloudflare services

You can build a full application on Cloudflare while keeping an existing database elsewhere. Choose each additional service for the work it needs to do.

Cloudflare serviceIts job alongside Hyperdrive
WorkersRun endpoints and server-side application code
Pages FunctionsRun server-side routes for an existing Pages application
D1Store SQLite data for applications that choose D1
R2Store files while PostgreSQL holds their metadata
KV or the Cache APICache application values or HTTP responses explicitly
QueuesDeliver background jobs to a consumer that writes through Hyperdrive
WorkflowsRetry and coordinate multi-step background work
Durable ObjectsCoordinate access to an entity or manage realtime connections
Workers AI and VectorizeRun models and semantic search around authoritative SQL records
Workers VPC and TunnelReach a database inside a private network

Pages Functions and Astro

An existing Cloudflare Pages site can add a Hyperdrive binding to its Functions environment. The database query runs in the Function, using context.env.HYPERDRIVE. The browser calls that Function over HTTP. Pages documents Hyperdrive bindings here.

For Astro, keep database access in server-rendered routes or server endpoints using the Cloudflare adapter’s runtime bindings. A prerendered page runs at build time; adding a runtime binding doesn’t turn it into a live database page.

In an AHA stack application, HTMX can request HTML from one of those server endpoints. The endpoint verifies the caller, queries PostgreSQL through Hyperdrive, and returns the fragment. Alpine.js manages browser interaction. Neither frontend library needs database credentials.

D1: choose where the relational data lives

Use Hyperdrive when you already have PostgreSQL or MySQL, need their features, or want to retain your current database provider.

D1 gives you a Cloudflare-managed SQLite database with its own bindings and operational model. If that’s what a new application needs, D1 avoids adding an external database host.

You don’t connect Hyperdrive to D1. You also don’t automatically gain PostgreSQL extensions, types, or behavior by moving a PostgreSQL application to D1. That’s a database migration with schema and query changes.

R2: files in object storage, metadata in SQL

For a document application, put the uploaded file in R2. Store its object key, owner, and processing status in PostgreSQL.

An authenticated download endpoint checks access using an uncached database read, then retrieves the R2 object. This keeps file bytes out of PostgreSQL while SQL handles relationships and permissions.

An R2 upload and a PostgreSQL transaction do not commit atomically. Record a pending status, retry failed steps, and clean up abandoned objects. Hyperdrive doesn’t turn operations across separate services into one transaction.

Queues: move database work off the request path

Suppose a note view should produce an analytics event. A Worker can send an event to a queue and let a consumer save it later through Hyperdrive. The request doesn’t need to wait for the SQL insert.

Queues uses at-least-once delivery, so a message can arrive more than once. Give the event a stable ID and make the database insert idempotent. See the delivery guarantees.

Create views.sql in the database used for analytics:

CREATE TABLE note_views (
  event_id uuid PRIMARY KEY,
  note_id integer NOT NULL REFERENCES notes(id),
  viewed_at timestamptz NOT NULL
);

For a separate consumer Worker with pg installed, save this complete file as src/index.ts:

import { Client } from 'pg'

export default {
  async queue(batch, env): Promise<void> {
    const client = new Client({
      connectionString: env.HYPERDRIVE.connectionString
    })

    try {
      await client.connect()

      for (const message of batch.messages) {
        const event = message.body
        await client.query(
          'INSERT INTO note_views (event_id, note_id, viewed_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING',
          [event.eventId, event.noteId, event.viewedAt]
        )
        message.ack()
      }
    } finally {
      await client.end().catch(() => {})
    }
  }
} satisfies ExportedHandler<Env, {
  eventId: string
  noteId: number
  viewedAt: string
}>

Use this consumer’s wrangler.jsonc, replacing the Hyperdrive ID with a configuration whose runtime role can insert into note_views:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "note-view-consumer",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-24",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "hyperdrive": [
    { "binding": "HYPERDRIVE", "id": "YOUR_HYPERDRIVE_ID" }
  ],
  "queues": {
    "consumers": [
      {
        "queue": "note-views",
        "max_batch_size": 10,
        "max_concurrency": 2,
        "dead_letter_queue": "note-views-failed"
      }
    ]
  }
}

Create both queues before deploying the consumer:

npx wrangler queues create note-views
npx wrangler queues create note-views-failed
npx wrangler types
npx wrangler deploy

In the original API Worker’s configuration, add the producer binding:

"queues": {
  "producers": [
    { "binding": "NOTE_VIEWS", "queue": "note-views" }
  ]
}

After regenerating that Worker’s types, the producer call inside a successful detail request is:

await env.NOTE_VIEWS.send({
  eventId: crypto.randomUUID(),
  noteId: id,
  viewedAt: new Date().toISOString()
})

Place it after the missing-note check, inside if (id !== undefined). This waits for queue acceptance, not for the consumer’s SQL insert. Decide whether analytics delivery failures should fail the request or be recorded separately.

The consumer acknowledges each message only after its insert succeeds. If it crashes after an insert but before acknowledging, redelivery hits the unique key and does nothing. Monitor the dead-letter queue for events that keep failing.

These messages come from trusted application code. If you accept arbitrary messages through an external producer, validate their fields before querying. A TypeScript type doesn’t validate data at runtime.

For a business event that must never be lost between a database commit and a queue send, use a transactional outbox: write the event into PostgreSQL in the same transaction, then publish it with a retrying dispatcher. The two services don’t share a transaction.

Workflows, scheduled jobs, and Durable Objects

A Cron Trigger can run a periodic SQL query through Hyperdrive. Use it for bounded jobs such as collecting yesterday’s totals. Make reruns safe.

A Workflow can query PostgreSQL inside a step, call another service, and save a result in a later step. Open clients inside the steps that use them. Don’t hold a SQL transaction across a durable sleep or external service call, and make retried writes idempotent.

A Durable Object is useful when you need one place to coordinate an entity, such as a room or a shared editing session. It can query external PostgreSQL through Hyperdrive, but its own SQLite storage may already cover the state it owns. Don’t add a second database without a reason.

A Durable Object’s local transaction doesn’t include PostgreSQL. If multiple applications can update the same PostgreSQL rows, preserve the database constraints and transactional checks there too.

Workers AI and Vectorize

For semantic document search, PostgreSQL can hold the original documents and ownership records. A background worker generates embeddings using Workers AI and writes vectors containing document IDs to Vectorize.

At search time, Vectorize returns candidate IDs. Your Worker checks access and loads the matching records through an uncached Hyperdrive configuration before returning content or sending it to a model.

Treat the vector index as a search aid that can lag behind the database. It mustn’t become the only permission check. If PostgreSQL already provides the vector search you need, keeping that search in the database may be simpler.

Connect Hyperdrive to a private database

A database doesn’t need a public listening port if you configure private connectivity.

Cloudflare currently recommends its Workers VPC integration, documented as beta, for this setup. A TCP VPC Service describes the database endpoint, and cloudflared runs in a network that can reach it. Hyperdrive connects through that service and tunnel. Follow the private database guide.

Hyperdrive connecting to a private databaseThe Worker uses Hyperdrive, which reaches a private PostgreSQL server through a TCP VPC Service and an outbound Cloudflare Tunnel.WorkerHyperdriveTCP VPC ServiceCloudflare TunnelPrivate PostgreSQLserver

Set up the tunnel, create the TCP VPC Service with the PostgreSQL application protocol, then create a Hyperdrive configuration referencing its service ID. Keep database TLS configured.

There is also a Tunnel and Access integration, using an Access application and service token. The VPC path avoids that extra Access token setup.

The Worker code still uses env.HYPERDRIVE.connectionString. Private routing changes the origin connection configuration rather than your SQL queries.

Measure performance before adding more services

Compare the operations your application performs, including writes and reads that can’t be cached. A benchmark made entirely of warm cache hits won’t tell you how an editing screen behaves.

Track request latency, query latency, database CPU, active origin connections, and cache hit rate. Test from the regions your users are in. Change one part of the setup at a time.

If a request makes several sequential database queries, reducing those round trips may help more than caching. Use joins or suitable batch queries where they make sense. Inspect slow SQL with PostgreSQL’s query tools.

Workers placement can help database-heavy handlers by moving execution closer to backend infrastructure. It also changes the distance to other services the Worker calls, so measure the whole request before enabling it. Cloudflare explains placement here.

Pricing, limitations, and common problems

As checked on October 24, 2026, Hyperdrive is included in Workers Free and Paid plans. Free includes 100,000 database queries per day; Paid lists unlimited Hyperdrive queries. The count covers database statements, including writes and schema changes. Workers execution and your database provider still have their own charges and limits. Check the current pricing.

Origin connections, configuration counts, and query execution have separate limits. A Paid plan doesn’t mean an unlimited number of connections to your PostgreSQL server. Review the limits alongside your provider’s connection allowance.

SymptomWhat to check
Configuration creation failsDirect database hostname, credentials, firewall, TLS, and database reachability
Binding is undefinedBinding name, selected environment, and regenerated Wrangler types
Local works, deployed failsLocal development bypasses the remote configuration; check its database role and network path
A read returns an older value after an updateQuery caching, plus any separate HTTP or application cache
Connections fail under loadLong transactions, consumer concurrency, and the combined budgets of all configurations
Every request is still slowUncached network trips, SQL execution, missing indexes, or an overloaded database

Some PostgreSQL features don’t work through Hyperdrive, including LISTEN/NOTIFY, advisory locks, and SQL-level prepared statement management. Driver-supported PostgreSQL prepared statements are a separate case. Check the supported features before moving an existing application.

Keep a separate direct database path for administration, migrations, or unsupported operations when needed. Don’t put the migration role into the public-facing Worker.

How I would use Hyperdrive

If I had an application already using PostgreSQL and wanted to move its HTTP endpoints to Workers, I would keep the database and start with one uncached Hyperdrive configuration.

I would move a read-only endpoint first, compare its behavior with the original, then move authenticated writes after checking transaction and authorization behavior. Public caching would come later, only for queries whose stale results are acceptable.

For a small new application that fits D1 well, I would consider D1 before adding external PostgreSQL. For a Node.js application already running beside its database, I would keep the existing pool unless moving execution to Workers solved a concrete problem.

The useful migration is often keeping the database you trust while removing the small server you maintained just to connect your frontend to it. Workers runs the endpoints, Hyperdrive manages their database connections, and your SQL data stays in place.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: