Operating

External Databases and Hyperdrive

Drupflare stores a site in the Durable Object that serves it. There is one object per site and it holds both the interpreter and the SQLite database. That is the default.

An external database is an option for self-hosted deployments. It is not offered on the managed tier. The limits listed on Why the Default Is Not External, such as the configuration cap, the daily query limit and the missing MySQL COM_STMT_PREPARE, are properties of a Cloudflare account, and a workerd an operator runs has no account behind it. An external database also makes a site portable, and it moves site storage off the 5 GB account-wide storage cap, which otherwise bounds how many sites a free account holds.

Pages

Selecting One

DB_BACKEND chooses: do-sqlite (the default, the object's own storage) or hyperdrive. Choosing hyperdrive with no HYPERDRIVE binding is reported as misconfigured and does not fall back, because a site running on a different database from the one its operator chose is worse than one that refuses to start.

The clients are pg and mysql2, ordinary npm dependencies in the Worker. Hyperdrive supplies no client. It supplies a pooled endpoint and a connection string, and Cloudflare's own get-started says "you will need a database driver". Both clients are imported dynamically, so a deployment that never selects this backend evaluates neither. A client is opened for each statement and closed after it, so the object holds no socket between statements.

The connection string's scheme picks the client: postgres://, postgresql:// and mysql:// are what Hyperdrive itself accepts. There is no second setting to disagree with the string.

Two differences between the databases are handled in the adapter (src/db/pg-exec.ts), and the driver does not know which one it is on:

PostgreSQL answers a SELECT as {rows: [...], rowCount: n} and an INSERT as {rows: [], rowCount: n}. MySQL answers a SELECT as an array and an INSERT as a header object carrying affectedRows. The adapter reduces both to the one shape the host's own SQL seam uses.

cfwSqlExec is synchronous, the same property that puts KV, R2, D1 and env.ASSETS.fetch() out of reach of a read, and a pg query is not. So the driver yields the statement instead: rom's CfwSqlClient opens a cfwpark+sql:// pseudo-socket, ext/cfwpark freezes the continuation, the Worker runs the query, and the chain resumes inside the same invocation. The HTTP transport rides the same trap under a different scheme.

A refused yield is an error and not a fallback. A refused fetch falls back to the deferred one, but there is no local copy of an external database to answer from, so a refusal reaches Drupal as a database error.

Verifying It Without a Deploy

localConnectionString makes wrangler dev connect straight to a database, so the whole path is drivable against the rig:

docker compose -f docker/compose.yml up -d postgres # or: mysql
export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://drupflare:[email protected]:5432/drupflare"
# mysql://drupflare:[email protected]:3306/drupflare

The hyperdrive binding is not in the canonical wrangler.jsonc. A binding that names a configuration that does not exist refuses the deploy, so it is an opt-in addition for a deployment that has one.

/backend (diagnostic-gated) runs SELECT 1 through the binding from inside the object and reports the dialect and latency. It never echoes the connection string.

What Hyperdrive Is For

Hyperdrive is connection pooling and query caching in front of a database you already run. It does not store data. You point it at a PostgreSQL or PostgreSQL-compatible database, or a MySQL database, and it gives a Worker a pooled connection string on env.HYPERDRIVE.connectionString that a normal driver such as pg connects to. The pooling is the primary value: a Worker that dials a remote database directly pays TCP, TLS and authentication on every request.

Current Cloudflare documentation:

Property Value
Plan availability Free and Paid Workers plans
Free plan limit 100,000 database queries/day, reset 00:00 UTC
What counts as a query any statement, cached or uncached, SELECT through DROP
Configurations per account 10 on Free, 25 on Paid
Origin connections ~20 per configuration on Free, ~100 on Paid
Query cache defaults max_age 60 s, stale_while_revalidate 15 s
Query cache maximum max_age 1 hour
Maximum cached response 50 MB
Maximum statement duration 60 s
Initial connection timeout 15 s
Idle connection timeout 10 minutes
Egress charge none
Pooling and caching charge none beyond the plan limits

Exceeding the free query limit fails the operation with an error rather than billing for it. Hyperdrive does not support MySQL COM_STMT_PREPARE, so prepared statements have to be removed from MySQL queries; named prepared statements work only through postgres.js and node-postgres.

A cached query still counts against the daily limit, so query caching buys latency and origin load and no quota. The cache is keyed on statement text including comments, so a comment naming a volatile function such as NOW() can make a query uncacheable. Cloudflare does not document SQL comments as a cache-control API and recommends a cache-disabled configuration where a guaranteed fresh read is needed.

Cloudflare documents local development two ways. The Workers local-development page lists Hyperdrive among the bindings with no local simulation ("currently unsupported"), while a changelog entry and the Hyperdrive local-development page describe localConnectionString and CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<BINDING_NAME> connecting wrangler dev to a real database, including a remote one over TLS. The localConnectionString mechanism is the one that works.

A Hyperdrive binding is usable from inside a Durable Object. On a paid account, with a PostgreSQL 17 origin behind a Cloudflare Tunnel reached through a Workers VPC service, the object read env.HYPERDRIVE.connectionString and completed SELECT 1 in 6-9 ms of query time on three consecutive requests.

The path has three requirements:

What Hyperdrive Buys

Someone who already runs a PostgreSQL or MySQL database and wants Drupal in front of it gets pooling, a shared query cache, and no egress charge. If that database is the system of record for something other than the Drupal site, keeping it where it is may be the only correct architecture.

Beyond the site's own database, the tier src/ops/tcp.ts defines fits a secondary, host-side, deferrable read against a database the operator owns, dispatched by scheme through cfw_http_queue, with the endpoint set by the operator and never by the caller. A search index, an analytics sink or an external content feed has that shape.

A socket exchange that has to answer inside the request uses the park instead. A trapped socket call freezes PHP where it stands, the Worker performs the exchange, and the call returns with the answer in the same request. That is how drupal/redis runs, and how the site database runs on an external backend. It costs a network round trip per exchange where the Durable Object's own SQLite costs a local read, which is why SQLite is the default and the cache backend.

An external database also avoids the compatibility gaps that come from Durable Object SQLite and not from SQLite itself: the 100 bound-parameter cap, the 50-byte LIKE/GLOB pattern limit, integer reads that lose precision above 2^53, and the absence of NOCASE_UTF8 and REGEXP. They are documented under Platform Constraints in the technical report. Hyperdrive is a compatibility option. It does not add capacity.

What It Costs