Before: current points to releases/v1 and pending names the checked current.next symlink to releases/v2. On one filesystem, os.replace(pending, current) replaces the current link. After: a new open reads v2; an already-open file descriptor still reads v1. Rollback needs a fresh link to the recorded v1 target and another replacement.
A same-filesystem symlink replacement selects complete trees. Old asset URLs must remain reachable. This local pattern does not prove production migration, browser-wide transactions, automatic rollback, or power-loss durability.

Context

Copying changed files over a live tree exposes partial releases and makes the previous version difficult to name. Complete staging solves the partial-copy problem, but “rename the new directory over the old one” is not a sufficient publication procedure. mv new existing-directory normally moves new inside the destination. A plain POSIX rename cannot replace an existing non-empty directory. Renaming the old directory away and then the new one into place leaves a gap between two operations.

Decision

Keep immutable, versioned release directories and select one through a current symlink. Configure the file server to serve that stable path. After checking a new tree, create a temporary symlink beside current and atomically replace the symlink itself using a same-filesystem rename. The old release directory remains untouched under its versioned name.

This is the implementation pattern for the complete-tree decision, not evidence that a production host has already been migrated to it. An existing real-directory serve root requires a separately planned migration. Never run the example against that path and assume a symlink will replace it safely.

Rehearse the switch and rollback locally

The following Python 3 exercise creates only disposable local files. It demonstrates the pointer operation on a POSIX filesystem, without starting Caddy or changing a deployment.

from pathlib import Path
from tempfile import TemporaryDirectory
import os

with TemporaryDirectory() as tmp:
    root = Path(tmp)
    for version in ("v1", "v2"):
        release = root / "releases" / version
        release.mkdir(parents=True)
        (release / "index.html").write_text(version, encoding="utf-8")
    current = root / "current"
    current.symlink_to("releases/v1", target_is_directory=True)
    previous = os.readlink(current)
    with (current / "index.html").open() as in_flight:
        pending = root / "current.next"
        pending.symlink_to("releases/v2", target_is_directory=True)
        os.replace(pending, current)
        print("new open:", (current / "index.html").read_text())
        print("already open:", in_flight.read())
    pending.symlink_to(previous, target_is_directory=True)
    os.replace(pending, current)
    print("rollback:", (current / "index.html").read_text())

Expected output is new open: v2, already open: v1, and rollback: v1. The successful rename makes the directory entry replacement atomic. It does not change a file descriptor already opened on the old file, and it does not test power-loss durability or HTTP routing.

Consequences

  • No half-copied live tree: each release is complete before selection. Never mutate a selected or retained release directory.
  • Named rollback: replace current with a fresh temporary symlink to the recorded previous target. Rollback is an action to perform and verify, not something a failed smoke test does automatically.
  • Preflight evidence: check the staged manifest, file ownership, readable permissions, and available space before switching. Keep the manifest outside the hashed tree.
  • Serialized publishers: allow one deployment at a time. A fixed temporary link name is safe in this isolated exercise; production needs coordination and a collision-safe pending name.
  • Storage trade-off: retain previous releases long enough for rollback and in-flight clients; prune only after the retention window and checks.

Atomic pointer replacement is not a transaction spanning a browser page and all its asset requests. A browser can receive old HTML before the switch and request assets afterward. Fingerprinted asset URLs help only if the old asset bytes remain reachable at those URLs from the new served tree or a retained asset store. Merely keeping the old release directory elsewhere on disk is insufficient. Caches can also retain old responses after either publication or rollback.

The temporary link and current must be on the same filesystem; their common parent is a convenient way to ensure this. Check the filesystem and server’s actual symlink behavior. Rename atomicity concerns concurrent visibility, not survival after a sudden power loss; stronger durability needs file/directory synchronization appropriate to the filesystem. The served root is not a sandbox, so reject unintended symlinks within release content.

Why this beats in-place sync

The release can be named, verified before exposure, selected in one pointer operation, and restored from a known target. Those are useful guarantees without promising a globally instantaneous update, cross-request snapshot isolation, or automatic recovery after every failure.

Sources

  • Linux man-pages: rename(2), atomic replacement, non-empty directories, symlinks, and open descriptors, checked 2026-10-15.
  • Python os.replace, atomic successful replacement and non-empty-directory/cross-filesystem limits, checked 2026-10-15.
  • Caddy file_server, root resolution and symlink security boundaries, checked 2026-10-15.