Blog · Engineering ·
Live is the absence of a link
How Atelier's freeze ended up as one symlink and no mode flag — including the tidier first design that would have served settings.php, and the three exposures we only found by attacking our own frozen site.
This is the build. The plain version of what the feature does is over here, if you'd rather have that first.
Atelier can serve your public site in one of two ways: live, where Drupal renders each request, or frozen, where a versioned static snapshot is handed out as files. Owners wanted a site-wide release gate on top of our per-page publishing, and the exporter already produced the artifact. Nothing in the appliance could serve it.
The whole mode ended up being one symlink.
private/frozen/current -> private/frozen/20260912-0237-bluey
Why a symlink and not a flag
Four things need to know the serving mode: the console, the CLI, the container healthcheck and the web server. A row in the database is readable by exactly one of them without a PHP bootstrap. A symlink is readable by all four with readlink, so they cannot disagree — there is no window where the console reports frozen while the web server is still serving live, because there is no second copy of the truth to drift from.
Switching is one atomic ln -sfn in either direction. No restart, no cache rebuild, no state key. A freeze always re-exports before repointing: a snapshot stamped "just now" holding last Tuesday's render is worse than no feature.
The tidier design would have served settings.php
The first draft was symmetrical: Live = the symlink points at the docroot. One pointer, always valid, two destinations.
The serving rule is "if current/<path> is a file, send it." Aimed at the docroot, the first request for /sites/default/settings.php returns the site's database credentials as text/plain.
We caught it the same day, at build time, which is luck and not process. What we kept is the rule rather than the patch: the dangerous mode gets no representation. Live isn't a pointer at something real, it's the absence of a pointer — nothing to aim, so nothing to mis-aim. The console still shows Live as the zero-th row in the snapshot list, because that's a presentation choice and it does not have to be how the state is stored.
The bypass is a presence check, deliberately
A freeze that also freezes the owner is useless — every check would have to happen inside the studio preview instead of at the real URL. So a request carrying a Drupal session cookie skips the snapshot:
map $http_cookie $frz_cookie {
default 0;
"~*(^|;\s*)S?SESS[0-9a-f]+=" 1;
}
Presence, not validity. We don't verify the session at the edge. A stale cookie costs one Drupal render as an anonymous user — which is what that visitor would have received anyway — so there is no security decision riding on the check and no reason to pay for one. Saying that out loud in the config is what stops it being read as a hole two years from now.
Consequences worth designing for: a freeze logs nobody out, publish confirmations have to say visitors see this on the next freeze, and every snapshot is browsable under /atelier/snapshots/<id>/ so an owner can see what visitors see without signing out.
Then we attacked it
Phase one put the rule in the container's Apache vhost — a few lines, no new service, misses falling through to Drupal for free. Before shipping, we stood up a throwaway container and tried to get something out of a frozen site that shouldn't come out.
- A
.phpfile inside a snapshot executed. Apache's handler mapping cares about the extension, not about the tree being "just static output". A snapshot is in principle attacker-influenced content. - The snapshot marker was served.
.aincient-export.jsonrecords who froze the site and when. - The DDEV mirror served assets live while frozen. Inside an nginx
location, a matchedifdrops thetry_filesthat follows it, so the fall-through silently 404s instead of reaching Drupal. The rule had to be rewritten asmapdirectives and a per-requestroot, with noifanywhere.
Reading the rule had found none of these. Running it against hostile requests found all three in an afternoon.
So the topology changed
We stopped patching. An nginx edge is now the appliance's only published port; Drupal publishes nothing and trusts its single peer. Frozen, an anonymous GET or HEAD is answered from disk and a miss returns the snapshot's own 404.html with a real 404 — PHP never runs for a visitor at all.
# Any 1 = bypass the snapshot: a root that never exists sends try_files to @miss.
map "$frz_cookie$frz_path$frz_method" $frz_root {
default /srv/private/frozen/current;
"~1" /nonexistent/frozen-bypass;
}
The bypass is expressed as a document root that cannot exist, so a bypassed request takes the identical try_files path as a genuine miss. One code path, with the branch in the variable instead of the control flow.
The Apache and DDEV rules stayed, hardened with the same exclusions, as fall-through copies for image-only runs. Three byte-identical copies of a security-relevant rule is a maintenance liability we took on deliberately, and the file says so at the top.
The accident that turned out to be the point: we built the edge for containment, and it also bought continuity. When the appliance upgrades itself and app restarts, the edge is a separate process still holding the files — the public site stays up while the CMS underneath it is replaced.
Four things we'd carry to the next thing
- Make disagreement impossible, not unlikely. Two components that each store a mode will eventually differ. One artifact they all read cannot.
- Give the dangerous state no representation. The symmetrical model was the one that leaked credentials. Absence is safe in a way a pointer never is.
- A security rule is verified by attacking it, not by reading it. Three real exposures, none visible on the page.
- Don't authenticate where nothing depends on it — but write down why, next to the code, or someone will "fix" it.
Still open: a CDN purge hook (frozen responses ship max-age=300 with stale-while-revalidate, so a CDN converges on its own; purging after a switch makes it immediate) and hard-link dedupe between snapshots.
Atelier is an AI-first website builder built on Drupal that runs as an appliance on your own machine, with your own provider key. The source is on GitHub; install is one command.