All posts

Releasing a Static Site: Artifact Validation, Atomic Switching, and Failure Recovery

Using an Astro blog, connect checked artifacts, commit versions, live health checks, and failure recovery while keeping an explicit release cadence.

After a personal blog adopts static generation, the server has a simple job: deliver HTML, CSS, JavaScript, and images to browsers. But “just a collection of static files” does not mean releases can overwrite the directory arbitrarily.

Halfway through an upload, a page may already reference new assets that have not arrived. A deployment-time rebuild may differ from the build that passed checks. And a successful deployment command does not mean the domain is actually serving that version.

When connecting automated deployment for this Astro blog, I wanted clear answers to three questions: what is being released, what is being served, and where can we recover after failure? All paths and account names in this article are examples, with no actual server connection details.

Release the artifact produced by the same checked run

A pipeline can follow this sequence:

Commit → Install locked dependencies → Type checks and tests
       → Static build → Artifact validation → Upload artifact
       → Server validation → Switch version → Domain health checks

The deployment stage should download the checked artifact from the same run. Installing dependencies and rebuilding on the server would create a second result without that direct correspondence. The build should record its source commit identifier and write a simple release.json into the artifact.

A commit identifier alone is not sufficient for reproducible builds. The dependency lockfile, runtime version, and build variables also affect results and need to be pinned or recorded together. Since the blog uses SSG, production only needs static serving; a persistent Node process is unnecessary for delivering the pages.

Separate preparation from serving

Syncing build files directly into Nginx’s active directory exposes the preparation stage to users. I use version directories and symbolic links to represent release state:

/srv/site/
  releases/
    <commit-a>/
    <commit-b>/
  current  → releases/<commit-b>
  previous → releases/<commit-a>
  assets/

A new version first lands completely in its own directory. Only after validation does current change. In the target Linux environment, replacing the link by renaming a temporary link on the same filesystem makes the directory entry switch atomic. mv options differ between platforms; a server command should not be treated as a universal script for every system.

“Atomic” describes only the entry-point switch, not a guarantee that a browser visit touches just one version. A user may already have old HTML and request its old hashed assets afterward. Old assets therefore need to remain available; old versions cannot be deleted immediately after a switch.

Validate both transfer and version

I split validation into two layers: archive checksums confirm the transferred file has not changed, while manifest checks confirm the unpacked contents match expectations. Before activation, the homepage, release marker, and target directory are also checked to reject invalid versions or paths outside the controlled location.

The deployment account only needs access to the site’s release directory. The SSH private key is injected through Secrets, and the server identity uses a host key verified and pinned in advance, rather than disabling host checks for convenience. Available GitHub deployment-environment protections depend on the repository and account; not every private repository has the same options. The GitHub environments documentation explains these settings.

Checksums establish transfer integrity, but if both the package and manifest are replaced, they do not establish a trusted source by themselves. Provenance still depends on controlled workflows, artifact permissions, accounts, and SSH trust. Different mechanisms cover different stages; using SHA-256 does not by itself make the entire supply chain secure.

Health checks must follow the real serving path

After switching, the deployment script requests the domain’s release.json, verifies that its commit exactly matches this release, then requests the homepage. This can reveal that current did not take effect, Nginx points at the wrong root, or requests reach an old site.

Adding a version parameter to marker requests and disabling caching reduces interference from stale responses. If a CDN sits in front of the site, its cache rules also need to be explicit. Reading the marker only from the server’s disk cannot prove that the domain’s serving path is correct.

A 200 response from the homepage is still not complete page acceptance. Build checks, browser inspection, and other checks should cover core static resources, links, and accessibility. A health check quickly determines whether this version is actually being served; it does not prove every feature is correct.

Define when failure recovery runs

A deployment lock prevents two activation processes from interleaving. After obtaining the lock, save the original current, then switch and run health checks. Restore the old link on failure, and update previous only after success. Keep old hashed assets in a controlled asset directory so requests from pages already open do not fail.

Other cases need handling too: a first deployment without an old version, directory validation failure, same-named assets with different contents, and interrupted runs. Cleanup needs its own design and must not delete directories referenced by current or previous.

This recovery mechanism covers static artifacts and directory entry points. Disk failure, wholesale permission failure, or an unreachable server can make recovery on the same machine impossible. Applications with database migrations need separate data-compatibility and recovery strategies.

In this project’s history, real automated deployment has run successfully, and failure recovery has been tested locally. There has been no dedicated production fault-and-rollback exercise, so it cannot be described as “live failure recovery verified.” An existing previous link proves only that a recovery entry point exists.

Keep the release decision after automation

Automatically updating the site after a main merge reduces repetitive work, but does not mean every small edit should go live immediately. I now collect everyday content and interface changes on local feature branches for review, and only proceed through PR, merge, and the automated pipeline after an explicit request to release.

This lets automation perform the mechanical steps while collaborators retain control of release timing. For a personal site, directories, checksums, and a clear recovery path already address many problems. What matters is the evidence each step can provide and the action available when it fails.