Configuration

Runtime Settings

Settings for cron, warming, the interpreter lifecycle, replica mode, boot and storage. The index of every variable is on Configuration.

Cron

var default what it does
DRUPAL_CRON on whether Drupal cron runs from the alarm; 0 turns it off
CRON_INTERVAL_MS 900,000 minimum gap between firings
CRON_MAX_UNITS 6 hooks or queues one firing may run
CRON_MAX_ROWS 500 rows one firing may write
CRON_MAX_MS 500 wall-clock ms one firing may occupy
CRON_QUEUE_BATCH_SIZE 5 queue items drained per queue per firing
UPDATE_FETCH_URL unset release-history origin the declared prefetch warms; unset is drupal.org
GC_INTERVAL_MS 3,600,000 gap between garbage-collection passes
CACHE_DATA_MAX_ROWS 5,000 row cap on cache_data
WATCHDOG_ROW_LIMIT unset row cap on watchdog; unset reads dblog.settings from the site

CRON_INTERVAL_MS is what makes the per-firing budget a budget. The alarm is not a clock, since it re-arms at +1 ms while a fill queue is draining, so "once per alarm" during an active fill is once per page, each one costing an interpreter unit and up to CRON_MAX_ROWS writes. At 15 minutes the worst case is 96 firings/day, so a site that hit the row cap every time spends 48,000 of the 100,000 daily rows. Lowering it is the fastest way to burn the meter that binds regeneration.

Cron renders against the site's origin, so links in mail it sends point at the site. See SITE_ORIGIN.

Warming

var default what it does
KEEP_WARM_MS 240,000 idle alarm re-arm and the floor of the idle fade; 10,000 to 3,600,000; KV-overridable
IDLE_CEILING_MS unset ceiling of the idle fade; 10,000 to 3,600,000; unset keeps the re-arm fixed at KEEP_WARM_MS
SITE_WARM by plan 1 warms, 0 never warms; unset warms a paid site and leaves a free one to the thermal policy
WARM_INTERVAL_MS 8,000 the warm re-arm, KV-overridable; clamped to 600,000, or to 8,000 when RETAIN_INTERPRETER=0
WARM_CEILING_MS unset ceiling the solved warm interval may climb to; 9,500 to 120,000; KV-overridable

A Durable Object hibernates after 10 seconds of idle, and hibernation discards in-memory state, so this.php dies there. Measured on a deployed worker: an object holding a 32 MB allocation and an id minted in its constructor kept one incarnation across 71 consecutive 8 s alarms, and at 12, 20, 30 and 45 s the constructor ran again on every probe.

KEEP_WARM_MS ships at 240,000, which is 24x the threshold, so it re-arms an idle alarm and keeps nothing warm.

IDLE_CEILING_MS lets that idle re-arm grow with idle time. For a site the predictor does not keep warm, the next re-arm is the time since its last render, clamped between KEEP_WARM_MS and IDLE_CEILING_MS, so each wake lands twice as far from the last activity as the one before. The floor holds while fill, mail or HTTP work is queued, while a pool has lanes, and during a migration, a stepped job, an update run or a repair. With the ceiling unset the re-arm is the fixed KEEP_WARM_MS, as before. The clock is the render window row: its sixth field, lastAt, records the newest render when the window rolls, so the fade adds no row. After a hibernation the stamp can be a window old, so 15 minutes come off it and the fade starts late. Only renders stamp it; a cached page served by the object does not. /serve-stats reports rearm with the interval in force, the reason and the idle time. No deployed run measures this yet, so neither lever has marks on the settings form.

The */5 fill window skips a site whose queue is empty: the object answers the socket request with a 204 and x-cfw-window: skipped:empty-queue, and the driver reports skipped.

SITE_WARM re-arms at WARM_INTERVAL_MS instead. At 8,000 the object never hibernates, so every request finds the interpreter it left. A longer interval lets it hibernate, and the next instance then adopts the retained interpreter only when it lands in the same isolate; without retention (RETAIN_INTERPRETER=0) nothing can be adopted and the interval stays clamped below 10,000.

WARM_CEILING_MS sets the ceiling the solved warm interval may climb to, from 9,500 to 120,000 ms. The solver starts at 8,000 ms, steps 500 ms below 9,500 and 2,000 ms above, and takes one step per two clean 15-minute windows. Above 9,500 ms a window is clean when it had at least one render and no cold boot; at or below it a window is clean when one incarnation spanned it, as before. A window with a cold boot sends the interval back to 8,000 ms, and a window with no render holds it. /serve-stats reports warmCeilingMs and warmWindowColds beside warmDecision. The var has no effect when RETAIN_INTERPRETER=0, which keeps the ceiling at 9,500, or when WARM_INTERVAL_MS is set, because the explicit interval wins. Why it is off: on one demo object on one colo (2026-10-31) the object stayed hot for gaps up to 40 s with warming off in one drive, and a repeat seven hours later read 12 of 12 cold at 40 s; an earlier 12-worker rotation read 30 s adopting 36% of idle visits, with four hosts never adopting. No fixed interval past hibernation is supported by that evidence. The lever lets one site find its own ceiling instead of taking the fixed one.

The default follows the economics. An explicit SITE_WARM always wins. Unset, a paid site warms: one site's 10,800 firings a day sit inside paid's included Durable Object requests and rows. Unset on free it is the thermal policy below, because the same firings are 10.8% of free's daily row and request budgets, and whether that buys enough is the operator's call. Both levers are on /settings. An idle tick charges one row, the setAlarm itself. What warming buys is the 1,398 ms cold boot on every page that renders, which is the authenticated tier; a cached page answers off SQL without booting PHP at all, so warming cannot make one faster by any amount.

Past hibernation the curve has one knee, at 30 s. Measured on paid deployments with a page that needs PHP, requested after 150-300 s of idle. The 30-120 s rows come from 12 workers over four phases, rotated so every worker ran every interval (288 visits).

re-arm firings a day share of free's budgets paid, per site-month adopted the request
8 s (SITE_WARM) 10,800 10.8% $0.373 always never hibernated: 156-371 ms
30 s 2,880 2.9% $0.099 36% 100-692 ms adopted, ~2.1 s not
60-120 s 720-1,440 0.7-1.4% $0.025-0.050 14-15% 106-888 ms adopted, ~2.2 s not
240 s 360 0.4% $0.012 ~2% booted: 2-4 s

Past 10 s, adoption depends first on where the platform places the object. Four of the twelve workers never adopted at any interval; on the other eight, 30 s adopted 54% and 60-120 s 21-23%. Every visit that did not adopt found no retained interpreter in its isolate.

A site that is not warming re-arms at 240 s, whether the thermal policy declined it or an operator set SITE_WARM=0, because that is the cheapest point and the thermal policy declines exactly the sites whose firings cost more than the boots they save. 60-120 s cost two to four times the firings for little more. An operator who wants the middle point sets SITE_WARM=1 and WARM_INTERVAL_MS=30000 on /settings; 30 s is also the shortest interval under $0.10 a month.

The interval is priced per site rather than flat. A flat 8 s re-arm is 10,800 object requests and 10,800 rows a day whatever the traffic, 10.8% of the free daily budget for one site, and it is charged at both ends of the band it is only worth paying inside. src/ops/thermal.ts keeps a 64-entry ring of recent arrivals (in memory, so the decision costs no rows), estimates the probability of a render inside the hibernation threshold, and warms only while that probability times the 1,398 ms boot exceeds what a firing costs. Below roughly 505 renders/day it declines; above roughly 8,640 the site never idles 10 s and is already resident, so the warming would be redundant rather than harmful.

An active session overrides the rate estimate, because the rate is the wrong predictor for one. renderRate() is a property of anonymous traffic: it decides how likely a visitor is to arrive. What decides whether an expensive render is imminent is whether somebody is signed in and working, and an editor on a quiet site sits far below the 505 renders/day crossing while producing exactly the requests a cold boot hurts most. In measurement, an authenticated render took 31 ms on a warm object and 513 ms on a cold one. A compiled plan removes the render, not the boot: the first three requests of a session still render, and any request that falls through the plan tier pays whatever the object costs at that moment.

So an authenticated render keeps the object warm for 30 minutes on its own. It is a window rather than a latch, so a session that ended stops paying within one window, and at the 8 s re-arm it costs 225 firings, 225 requests and 225 rows, once. The signal is the uid Drupal reported rather than the presence of a cookie, so a stale cookie cannot arm it.

An explicit SITE_WARM still wins in both directions, including over an active session. Setting it to 1 warms a site the estimate would decline, and 0 declines one the estimate would warm. The predictor decides only when the var is unset.

After an editorial save the object prewarms one representative per route family rather than arbitrary URLs, because the page cache is keyed on the URL and what a visitor arriving on /node/41 meets cold is the object, not the page.

Duration is not the meter. An object waiting on an armed alarm is idle and eligible to hibernate, and an idle-eligible object is not billed for duration; warming spends requests and rows.

Interpreter Lifecycle

var default what it does
RECYCLE_ABOVE_BYTES 117440512 drop the interpreter at the end of an invocation above this linear-memory reading; floor 32 MiB
ISOLATE_ABOVE_BYTES 130023424 the same drop against the whole-isolate estimate (linear memory plus the JS side); floor 64 MiB
FILL_SETTLE_MS 60,000 ms after a boot before the alarm runs PHP (fills, cron, reconcile); 0 is off
RETAIN_INTERPRETER on keeps an evicted instance's interpreter for the next instance to adopt; 0 is off

RECYCLE_ABOVE_BYTES is the backstop for the isolate's 128 MiB limit, behind the primary defence. USE_ZEND_ALLOC=0 means PHP returns nothing between requests, so demand inside one incarnation is cumulative; /__migrate and /__firstrun drop the interpreter when they finish, and this catches everything else. It must fire between invocations: linear memory is reclaimed only when the old module is collected, so dropping mid-request holds both allocations at once. /serve-stats reports recycles and lastRecycle; an object recycling every request is paying a boot per page.

The limit is per isolate, not per object, and an isolate can host several objects of one class. /serve-stats reports isolate: its id, the interpreters it holds and their linearBytes added together. Two interpreters do not fit 128 MiB, so interpreters above 1 is the thing to look for on a replica pool.

RETAIN_INTERPRETER keeps the interpreter in the isolate when the instance is evicted, so the next request skips the boot while the isolate survives. In measurement, adopting a kept interpreter took 61-169 ms against 1-2.5 s for a boot. When the isolate is gone the next request boots as it would have anyway. An adopted interpreter is refused when the site committed a write since it was kept. /serve-stats reports retention.

Replica Mode

The replica variables are on Replica Settings. This section covers the read-only interlock and the calls between a primary and its replicas.

REPLICA_READ_ONLY is a safety interlock. Setting it wraps every installed cfw* capability so the object cannot commit an authoritative write, and refuses anything unrecognised. The mode is reported under replica in /serve-stats.

A replica is started by a bulk copy and kept current by a log, both over /__replica:

call on what it does
GET ?action=snapshot primary the table plan; 409 when the primary's own identity is unminted
GET ?action=snapshot&table=&offset= primary one page of one copyable table
POST ?action=restore replica lands one chunk; VERIFIED when the whole copy has arrived
GET ?action=log&since= primary the records a replica at that generation still needs
POST ?action=apply replica applies one record
?action=stage&to= either moves the stage, 409 on an illegal move

A replica refuses a request it may not answer with 421 and x-cfw-requires-primary, and refuses a stale one with 503 when the caller sent x-cfw-require-generation.

Boot and Storage

var default what it does
HEAP_SNAPSHOT on restore a stored wasm heap instead of booting the kernel; 0 disables
HEAP_IMAGE off take that heap image in the first place; 1 enables. A restore costs more than a boot
BOOT_IMAGE off boot a cold object from the published boot image when the site matches it; 1 enables
BOOT_IMAGE_BAKE off lets an object capture its own boot image; only the local bake rig sets it
HEAP_RESTORE_CHUNKS all at once chunks of a snapshot one invocation may apply
LAZY_MOUNT 1 in the canonical config stream the pack per file instead of inflating it whole
LAZY_FS_BUDGET_BYTES 20 MiB, 4 MiB in the canonical config resident bytes the lazy filesystem keeps before evicting
SITE_DB_PREFIX the packed default which database pack an object mounts
SQL_CHUNK_PREFIX drupal-sql which asset directory the migration chunks are read from
MIGRATE_ENGINE sql sql replays chunks in JavaScript; php needs a pdo_sqlite the shipping binary does not carry
MIGRATE_SELF_DRIVE on whether a partial migration re-arms its own alarm
MIGRATE_CHUNKS_PER_INVOCATION 40 on free, all on paid chunks replayed per invocation

OPCACHE_MODE

value what it does
off default; opcache disabled
file opcache on with the file cache as its only backing store
shm opcache on with shared memory as its backing store
pack reads a file cache baked into the pack, read-only

off is the default. file writes its cache into the in-memory filesystem and never reads it back, so opcache reports itself disabled on that arm. shm accelerates scripts but places its arena in PHP's linear memory; in one measurement that took an object to 191.25 MiB, past the 128 MiB isolate limit. off renders no slower than file and leaves more memory free.

pack needs bun scripts/bake-opcache.ts first and mounts nothing without it. It loads the baked cache as a second layer and cuts a cold render's object CPU by roughly 500-900 ms (deployed, n=8 per arm, with the levers swapped between deploys as a control). It holds 8.58 MB more of the isolate, and the visitor's wall time did not follow the CPU across the swap. Re-bake after any driver or composer change: scripts are never revalidated against their source, so the mount refuses a layer baked from other sources.

ARGON2

1 hashes passwords with argon2id at m=19456 KiB, t=2, p=1, computed on the host. Default off.

Turning it on is a migration: every bcrypt hash on the site reports as needing a rehash, and each account is upgraded at its owner's next login. Existing hashes keep working throughout; core's password service stays in place as the inner service and still owns bcrypt and legacy $S$ hashes. Hashes are written in PHP's own $argon2id$v=19$m=..,t=..,p=..$salt$tag form, so a site that leaves this platform can verify them on any PHP with ext-argon2.

A hash costs a 19 MiB transient allocation and two passes of CPU. That is more than a free-plan login invocation has.

SHELL_ASSEMBLY

Allows an authenticated GET to be answered from a stored shell with its personalised regions filled at the edge. On by default with no plan branch; 0 opts out, and an empty value is treated as unset. Assembly writes rows, described below. No visitor is served an assembly that has not been proven against their own render.

A path with no stored shell is seeded from the next authenticated request, which is what lets assembly resume after an invalidation. Every bump purges every shell, because no Drupal cache tag reaches one, so without seeding the feature stops at the first content change until an operator re-harvests.

An operator harvest takes two sessions of one role set and stores the shell only when both normalise to identical bytes. A seed takes one session, which widens what may be stored and leaves what may be served where it was. A visitor whose permissions hash differs from the stored shell's falls through to an ordinary render.

Each visitor's first request for a harvested path re-harvests under their own session and requires their normalised shell to equal the stored one byte for byte. That response carries x-cfw-cache: VERIFY and is answered from their own harvest; later requests carry x-cfw-cache: ASSEMBLED. A shell that disagrees is deleted and the reason is recorded in cfw_meta under shellRefusal.

The proof is stored per (path, permissions hash, uid) and voided when the path is harvested again. It costs 40 to 52 rows written once per visitor and path, against 4 to 10 for each render it then replaces; an assembly writes none.

A generation bump drops every stored shell, including a cachetags bump. A shell caches the shared region of a page and Drupal has no cache tag pointing at it, so nothing else would invalidate it. Assembly stops on that path until the shell is harvested again.

HEAP_SNAPSHOT gates the restore and HEAP_IMAGE gates the producer. The producer is off by default; HEAP_IMAGE=1 opts back in.

It is off because a restore costs more than the boot it replaces. Two deployed free workers differing only in these two vars, cpuTime on the cold render, nothing asked of either object during the idle:

arm cpuTime (ms) n median
HEAP_IMAGE=1 and HEAP_SNAPSHOT=1 2020, 1908, 1937, 1561, 1912 5 1,912
both 0 1277, 1343, 1113, 1251 4 1,264

The ranges do not overlap, so a restore cost about 648 ms more than booting from scratch, and it consumes storage against an account-wide 5 GB cap. The cause of the extra time is not known.

The image size depends on when it is taken. snapshotStep() takes its image from an alarm that finds no resident interpreter, so on a fresh site it captures the cold shape at 37,158,912 bytes. An image taken after a kernel boot and a serve is 9,699,328 bytes, and a restore from that image was measured at 314.5 ms faster than booting.

snapshotStep() still takes one image per pack generation from the alarm when opted in, and /heap reports imagedGeneration, imageAttempts and lastHeapImage. It never drops the interpreter: it runs before the fill loop and only when this.php === null. It also refuses to overwrite an image an operator took, since the GC keeps exactly one. When an image is stored its chunks are deflated, which bounds the storage at 3,134,727 bytes rather than 11,206,656.

The image is taken before the first render. Byte-identical anonymous output does not prove that no session state survives a render, so no post-render image is produced. The shared boot image below is the one exception, and it earns it differently: it is taken once from a throwaway site, and its test asserts the session id, the $_SESSION keys and the user after a restore.

The shared boot image

BOOT_IMAGE=1 boots a cold object from a heap image published with the worker, where a plain boot builds that heap from the database. It is off by default, because no real site's cold-boot share has been measured against it. bun run assets:bootimage bakes it into assets/bootimage/.

A boot restores an image only when the site equals what the image was baked from: the pack generation and enabled modules, the driver digest, a digest of the config rows, and a digest of the settings and PHP fragments the heap was built under. Any difference boots plain, which is always correct, so a stale image costs a plain boot. Two images are published. The pack image is for a site the pack made. The claimed image is for a claimed site and carries config=reset in place of the digest, because every claimed site has its own name, mail and timezone; its restore also resets the config factory and the entity memory cache.

After the pages land, the restore runs src/site/php/boot-image/rekey.php: it reads settings.php again (the site's salt and origin), flushes cache.memory and every memory bin, resets every cache collector (State's private key, and anything the bake computed but had not persisted) and reseeds mt_rand. A replica or pool lane never boots from the image.

The image is checked four ways. The first three were each rerun with the step that serves them removed, and the spec failed.

A heap restored from a bake is only as fresh as an interpreter that rendered once. The bake's active trail for /user/login sat in a cache collector, waiting to be persisted, and the first request's resetter wrote it into the new site's cache under that site's current tag checksums. It read as valid and lacked a link the site had added. Resetting every collector on each restore prevents this.

Deployed, on a claimed site with SITE_WARM=0 and RETAIN_INTERPRETER=0, a cold round cost 2,527 ms of object cpuTime without the image and 1,105 ms with it (medians, n=7 per arm, one worker each), and 3,974 against 2,454 ms of wall time. The cost is the request's fetch plus the alarm it queued behind, since the platform charges the boot to the alarm. The technical report has the table and its limits.

What is not measured: the share of real cold boots that match an image, the pack image deployed, a site with content or a large module set, and what the extra 18.6 MiB of memory (1,320 to 1,375 pages against 1,024) costs per fresh isolate, which a deployed memory is charged for in full. A site with a different module set, settings or interpreter build boots plain; the published index also names the interpreter, and tests/node/boot-image-agrees.spec.ts fails when it is not the one on disk.

What replaces this as the number to watch is the cold-encounter share, not the cold-boot duration. src/ops/cold-encounter.ts classifies every request the object handles and /serve-stats reports coldOfPhp, coldOfObject and coldOfTraffic. 1,264 ms is the floor for an object thermal.ts decided not to keep warm, and that policy already ships.

Read coldOfTraffic, not coldOfObject. A plan hit, an isolate memo hit, a caches.default hit and a KV page read all return from the front worker, so the object's own denominator is the leftovers and a share taken against it reads several times high. The front worker reports what it absorbed on the next request that hops anyway, under x-cfw-absorbed, and coldOfTraffic is null until it has, because a worker too old to report and a site with nothing absorbed are otherwise the same reading.

Changing LAZY_MOUNT, MIGRATE_ENGINE or SITE_DB_PREFIX in a deploying config means changing assets/.assetsignore too. The shipping asset set is derived from the canonical values of those three; tests/unit/runtime/assets-ignore.spec.ts fails when the two disagree.