Shipping a static site without mixing releases
A failed upload left C62Y serving new HTML with old assets, so even a small static catalogue learned to build immutable releases and activate them once.
C62Y’s first deployment process was a folder copy with confidence.
I generated the catalogue locally, opened a few pages, and uploaded the changed files into the live directory. Most releases completed quickly enough that the method looked adequate for a personal static site.
One upload stopped after replacing the catalogue HTML and before transferring the new stylesheet and image manifest. The live site served one page from the new build and assets from the old one. A product image path no longer existed. The layout used class names the active CSS had never heard of.
Nothing in the process could answer which version was deployed because “the version” had never been a first-class thing. There was only a directory being mutated toward the contents of my laptop.
The project was small. The ambiguity was not.
File-by-file deployment exposed a changing product
The upload process made every intermediate file copy public.
If HTML changed first, it could reference missing assets. If assets changed first, old HTML usually ignored them, but cleanup could remove files the old page still needed. Updating templates and data separately could produce routes or navigation from different source states.
I had thought of deployment as transferring the completed site. Operationally, I was editing the live site one file at a time.
The difference mattered even when the window lasted seconds. A crawler, reader, or my own smoke test could arrive during it. A failed transfer could make the window permanent.
Retrying the upload did not restore certainty. It might complete the intended set, skip files considered unchanged, or leave obsolete files whose relationship to the current build was unknown.
The fix began by treating the build output as one artifact that must become active through one transition.
Every build received a directory
The build generated a complete release directory under an identity derived from source revision and build time. It contained HTML, styles, scripts, image derivatives, feeds, and a manifest.
The directory was immutable after successful generation. A correction produced another release. I did not edit a file inside the existing output and preserve its name.
The manifest listed:
buildId
sourceRevision
generatedAt
routes
assets
assetDigests
recipeVersionThe build ID appeared in a response header or quiet development footer. The source revision said what inputs I intended to build. Asset digests said what the build actually produced.
This was not a fully reproducible build by later standards. Tool and environment versions were only partly controlled. The manifest still gave the artifact enough identity to distinguish releases and verify transfer.
The live directory stopped being the build target. It became a pointer to one completed release.
Upload was no longer activation
The deployment copied the new immutable directory to the server under a temporary name. The public site continued serving the current release.
After transfer, the server verified the manifest and renamed the directory into its final release identity. Only complete directories could reach that state.
Activation changed one symlink from the old release to the new one. On the server filesystem I used, the rename of the prepared link provided the single transition I needed.
The sequence became:
built → uploaded → verified → active
↘ failedA failed upload left a temporary directory and did not change the active site. A failed verification preserved evidence for inspection and could be cleaned later. A repeated deployment recognized an already uploaded release by its manifest.
The process did not make every server and network operation transactional. It narrowed public change to one atomic pointer replacement after the non-atomic work completed elsewhere.
The boring final step was the architectural improvement.
My original smoke test opened the local build before upload. It proved the source machine had a good result and said nothing about transfer completeness or server configuration.
The new process served the candidate release through a private or temporary path and requested it from the server. Checks included:
- The catalogue index returned a successful document with the candidate build ID.
- A representative product route contained identity, decision, and reference sections.
- The comparison route rendered selected product headers.
- Every asset named by those pages existed under the candidate release.
- The stylesheet and image manifest matched the candidate build.
- A missing route returned the expected response rather than an old file left in the directory.
The checks stayed small. They were not a second full test suite. They verified the properties most likely to differ between local build and deployed candidate.
One check deliberately requested an asset by its fingerprinted path and compared its digest. A successful file count could not detect a truncated or wrong file with the same name.
Only a candidate that passed became eligible for activation.
The responsive image pipeline already produced content-addressed derivative names. I extended fingerprinting to styles and scripts.
New HTML referenced new asset URLs. Old HTML retained its old references. The releases could coexist without one asset name changing meaning.
This supported long cache lifetimes and made activation safer. A reader holding an old document could still request the exact stylesheet and image derivative it named while the new release became current.
Cleanup therefore removed releases as complete units after a retention period. It did not delete assets merely because the current HTML no longer referenced them.
The first cleanup script scanned current pages and deleted apparently unused files from a shared asset directory. That strategy could not see old cached HTML or bookmarked versioned pages. Keeping assets with their release made ownership obvious.
The storage cost was acceptable for the small catalogue. Deduplication could come later if it preserved artifact references. Clarity came first.
Rollback became a real operation
Before immutable releases, rollback meant uploading an older copy over the current directory. It repeated the same partial-state risk under pressure and depended on finding the right local folder.
With retained releases, rollback selected a known-good release, verified that it still existed, changed the active pointer, and ran the public smoke checks.
Rollback did not erase the failed activation from history. The deployment log recorded candidate, prior active release, activation time, result, and rollback target.
The current data model remained static and did not require a database migration, which made this rollback comparatively simple. I documented that limitation instead of presenting the pattern as universal.
Even for a static site, rollback needed asset and server compatibility. A release built against a configuration no longer supported might not be safe. The retained manifest made that review possible.
The key improvement was psychological as well as technical: the recovery action was known and tested before the next failure.
The first release-directory implementation solved activation and accumulated every build indefinitely.
I added a retention policy that always kept:
- The active release.
- The immediately previous known-good release.
- A small number of recent successful releases.
- Any release marked for investigation.
Cleanup computed candidates, rechecked that none matched the active pointer, and removed one directory at a time. A failure could resume without affecting activation.
Temporary uploads had a separate age policy. An in-progress transfer carried a marker and lease time so cleanup did not remove it. An abandoned temporary directory became eligible after the lease expired.
The original combined script uploaded, activated, and cleaned in one sequence. Cleanup failure then made the entire deploy appear failed after the new version was already active. Separating lifecycle states let the deployment report activation success and cleanup debt honestly.
This was an early lesson in terminal status. One word such as “failed” can hide which consequence actually occurred.
The static files depended on server behavior for MIME types, cache headers, redirects, compression, and missing routes. I had initially treated that configuration as a permanent background.
One release introduced a feed extension the server served with an incorrect content type. The files were complete and the product was not.
I versioned the relevant configuration beside the build recipe and added checks for headers and redirects. Configuration deployment remained separate because the hosting environment did not allow it to activate under the same symlink. The release notes named the dependency and required the configuration change to land first or remain backward compatible.
I avoided putting environment-specific secrets or host paths into the generated artifact. The manifest recorded the expected configuration version, not sensitive values.
This exposed the limit of “static.” The application had no runtime database or server code, but delivery still depended on operational state. Static output reduced the surface and did not eliminate it.
C62Y was the small experiment that made this relationship concrete enough to keep.
The deployment log became evidence
The first script printed progress to the terminal and lost it when the session closed.
I wrote a compact append-only deployment record containing:
- Release identity and manifest digest.
- Upload start and completion.
- Verification results.
- Prior and new active release.
- Activation or rollback result.
- Cleanup state.
The record did not copy every transfer line. It preserved the semantic transitions needed to explain which release had become public.
If the script stopped after activation but before printing success, the server’s active pointer and deployment record could resolve the outcome. Rerunning did not need to guess from terminal memory.
I did not yet have a rich event model. The instinct began here: important operations deserved durable evidence at their consequence boundaries.
A log should answer what changed, not merely show that commands ran.
One early workflow rebuilt the site on the server after upload. The local preview and deployed result could differ because tool versions and paths differed.
I changed deployment to promote the exact generated release I had reviewed. The server received files and verified them; it did not reinterpret the source.
This placed more responsibility on the build environment and made preview meaningful. A release identity connected the pages I inspected to the artifact activated later.
I still ran server-side smoke checks because transport and configuration could change behavior. The distinction was clear:
- Build validation asked whether the artifact was internally complete.
- Candidate verification asked whether the server could deliver it correctly.
- Activation changed which verified artifact public requests reached.
C62Y used the separation at a scale small enough to understand end to end, which made it a credible foundation rather than an architectural slogan.
The personal site rarely had two deployments at once until an automated rebuild overlapped a manual correction.
Both uploads were safe in separate directories. Their activation order depended on timing rather than source intent. An older slow build could become active after the newer correction.
I added a deployment lock and a pre-activation check. The candidate recorded the source revision it intended to activate. Before switching the pointer, the script verified that it had not been superseded by a newer requested release.
For a one-person project, a simple lock file with expiry and owner information was sufficient. It was still treated as a lease rather than an eternal flag; an interrupted process could not block all later work forever.
This was a small concurrency problem with a durable lesson. Immutable work prevents corruption, but it does not decide which valid result should win.
Later preview controllers would separate branch request, build artifact, and activation eligibility more rigorously. C62Y exposed the race in miniature.
What the public health check could prove
After activation, the process requested the canonical site rather than the candidate path. It checked build identity, an important route, a fingerprinted asset, and the missing-page behavior.
Passing proved that the public routing path served the intended release at that moment. It did not prove every page was correct or every cache had updated.
If the public check failed, the process could reactivate the prior release and record both outcomes. It did not continue into cleanup.
I avoided a health check that returned success from a file inside the release while the main site still pointed elsewhere. The check needed to cross the same public routing boundary readers used.
Cache behavior complicated verification. The check added a controlled request header or unique query only where that did not alter application behavior and inspected the reported build ID. It did not declare stale caches broken merely because an older immutable asset remained valid.
The health check’s narrow claim kept it useful. It verified activation, not universal correctness.
The deployment script originally performed upload, activation, and cleanup as soon as it calculated a plan. A wrong path or retention assumption became visible only while it was changing the server.
I added a dry-run mode that printed the candidate release, files and total bytes to transfer, active and rollback targets, configuration expectation, checks to run, and directories proposed for cleanup.
The dry run did not pretend to predict network or server failure. It exposed intent before consequence. In one review, it showed that an empty local output would upload successfully and then propose activating a release with no routes because the build command had run from the wrong directory.
The real deployment added a minimum manifest invariant: required routes and assets must exist, and the output root must match the expected project. The dry run then made those validated facts visible.
Cleanup stayed disabled unless the plan had a confirmed active release and retained rollback target. A --force flag did not bypass the invariant; exceptional deletion happened through a separate explicit command whose scope could be reviewed.
This small separation between plan and execution suggests a broader approval model. A person should inspect the concrete action the system intends to take, not grant broad confidence to a script name such as deploy.
The safest automation was not the one with the fewest prompts. It was the one that could state its intended state transition precisely enough to run without improvising.
I copied files directly into the live directory and treated short inconsistency as harmless. A failed transfer made the mixture permanent.
I verified the local build instead of the uploaded candidate. Server delivery remained untested.
I shared stable asset filenames across releases and turned browser caches into another mutable state. Fingerprints made asset identity explicit.
I combined activation and cleanup in one success flag. Separate states revealed whether the public version had changed.
I kept no durable deployment record. A process interruption forced diagnosis from file timestamps and memory.
I allowed two valid releases to activate according to completion time. A lock and supersession check connected activation to intent.
The failures were not caused by the catalogue becoming large. They were caused by treating an important state change as a sequence of best-effort file operations.
A static site still has a release protocol
C62Y ended 2012 with a deployment model more deliberate than its size appeared to require. That was exactly why it was valuable. I could see every part:
- Build a complete immutable artifact.
- Give it an identity and manifest.
- Upload it without touching the active release.
- Verify the candidate through the server.
- Activate it once.
- Verify the public path.
- Retain a known-good rollback target.
- Clean old state independently.
The process did not require a cloud platform or elaborate orchestration. It required an honest model of what deployment meant.
The responsive redesign had taught me that a page was a system of rules rather than a screenshot. The failed upload extended that lesson: the site was not a folder of individually current files. It was one identified release delivered through an operational boundary.
That insight closed the first phase of the project. My next question was what happened when state moved continuously instead of once per release: a browser could receive every update and still be wrong about the present.