A CMS was too much and FTP was too little
M31V needed editorial validation, preview, and atomic releases without inheriting a general CMS or trusting mutable uploads as a publishing protocol.
M31V began as a small publication with articles, author pages, topic archives, a feed, and a handful of evergreen guides. Its content could fit in files. Its publishing responsibilities did not.
One obvious choice was a full content management system. Another was editing HTML locally and uploading it over FTP. The first offered far more product than the project needed. The second offered almost none of the guarantees a publication needed.
I built a narrow file-based publishing system between them. The decision was not “static good, dynamic bad.” It came from writing down which editorial and release promises M31V actually had to keep.
The unit of work was a publication change
The FTP version treated each file as an independent upload. Publishing one article could involve its page, home-page listing, author archive, topic archive, feed, sitemap, and related-navigation updates.
If the transfer stopped halfway, the live site contained a mixture. The new article might exist without appearing in the feed. An archive could link to a page not yet uploaded. A corrected author name might reach one surface and not another.
A CMS would normally coordinate those views from one database and render them dynamically. M31V could get the same consistency by treating the entire generated site as one build artifact.
The publishing unit became:
source content + templates + build code + asset inputs
-> validated immutable artifact
-> verified release
-> one activation stepFiles remained the authoring format. Individual files stopped being the deployment unit.
That distinction made the middle architecture possible.
I listed the capabilities the publication needed:
- Draft and published states with scheduled dates.
- Stable slugs and explicit redirects when URLs changed.
- Required title, description, author, and topic metadata.
- One content source for article page, archives, feed, and sitemap.
- Local and shareable preview of the actual candidate artifact.
- Link, asset, and metadata validation before release.
- Atomic activation with a retained rollback target.
- A history connecting source revision to deployed artifact.
- A low-dependency reading path after publication.
It did not need:
- Arbitrary page construction in a browser.
- Dozens of editorial roles and workflow states.
- A plugin marketplace.
- Per-request personalization.
- Comments or accounts in the publication runtime.
- Live collaborative rich-text editing.
The second list was important. Without it, a mature CMS always won by feature count. The project needed a smaller operational surface, not a claim that those features were generally unnecessary.
Architecture became a fit between guarantees and actual work.
Plain files were an authoring interface
Articles used a lightweight text format with a small metadata header:
title: A clear article title
slug: a-clear-article-title
author: robin
published: 2015-01-20
topics: [publishing, architecture]
description: One sentence used by archives and feeds.The body followed in readable markup. A writer could use an ordinary text editor, compare changes in version control, and keep content available without a proprietary database export.
Plain files did not make the system automatically friendly. Syntax errors, filename conventions, and obscure build messages could create a worse editing experience than a browser form. M31V treated validation and preview as core editorial interfaces.
The metadata schema stayed narrow. Adding a field required naming every surface that consumed it and how old content would migrate. I resisted turning the header into a dumping ground for presentation switches.
Files were valuable because they made source portable and reviewable. The surrounding tooling still had to respect writers.
The normalized content graph came first
The first generator let each template parse files independently. The article page read one title fallback, the feed used another, and the archive inferred date from the filename. Drift returned inside the build.
I introduced one normalization phase. It read every source, validated metadata, resolved authors and topics, calculated stable derived fields, and produced an in-memory content graph.
interface Article {
sourcePath: string;
title: string;
slug: string;
canonicalPath: string;
description: string;
author: Author;
topics: Topic[];
publishedAt: Date;
body: DocumentNode;
}Page, archive, feed, sitemap, and search-index builders consumed that graph. They did not reopen source files and reinterpret metadata.
This gave the static system a database-like center for one build without requiring a long-running publication database. The graph existed long enough to check relationships and generate a complete release.
One source did not mean one file per output. It meant one normalized meaning feeding every output.
A CMS form prevents some invalid states through controls. File authoring can represent almost anything, including mistakes.
M31V's validator checked more than syntax:
- Required fields existed and had the right type.
- Slugs were unique and matched URL policy.
- Authors and topics referred to declared records.
- Publication dates were valid and states agreed with dates.
- Internal links resolved within the candidate graph.
- Referenced images existed and included useful text alternatives where required.
- Redirect sources did not collide with live paths.
- Feed summaries met format and length expectations.
Errors named source file, field or line, consequence, and likely correction. “Build failed” was not an editorial interface.
Warnings covered conditions that might be intentional, such as an unusually long title or an orphaned draft. The release policy decided which warnings could ship.
The validator replaced part of a CMS backend. It encoded publication rules at the boundary between flexible files and trusted artifacts.
Draft was content state, not server secrecy
The generator excluded drafts from the public artifact. That did not make a source repository or preview URL automatically private.
Draft metadata determined inclusion:
status: draft | scheduled | publishedA scheduled article entered a candidate artifact only when the build's explicit publication time met its date. Builds received a clock value so preview could inspect future states deterministically.
Private previews used access controls appropriate to their host. I did not rely on an unguessable static URL to protect unpublished writing.
The public build also checked that no draft asset or source map leaked through a broad directory copy. Allow-listing generated outputs was safer than publishing the entire workspace and hiding a few paths.
File-based did not mean every file belonged on the web.
The earliest preview rendered one article through a development server. It did not include the generated archive, feed, redirect rules, or final asset paths. An article could look correct in preview and break after publishing.
M31V preview built the complete candidate artifact and served that directory. A small overlay supplied build identity and draft annotations without changing generated content semantics.
Reviewers could inspect article pages, navigation, archives, responsive behavior, and feed output from the same bytes proposed for upload. A shareable preview host received the artifact under a build-specific path.
The artifact was immutable. A source edit produced a new build identity and preview URL. Comments could name exactly which candidate they referred to.
This sacrificed the illusion that preview was instantaneous. Incremental development remained fast, but approval belonged to a completed candidate. Editorial confidence came from inspecting what would ship.
A build manifest made releases explainable
Every artifact included a manifest outside the public content root:
build ID
source revision
build-tool version
explicit publication time
article count
generated path count
asset count and fingerprints
redirect count
validation resultThe manifest connected a deployed directory to its inputs. It helped answer whether two hosts served the same candidate and whether a rollback restored the intended release.
I did not claim perfectly reproducible bytes. Tool versions, file ordering, and timestamps required additional discipline. The manifest made remaining variability visible and gave the project a stable release identity.
The public footer did not display a technical hash to readers. Diagnostics and deployment logs used it. A lightweight response header or metadata endpoint could expose release identity during verification.
Static output became an artifact with provenance, not an anonymous folder of HTML.
FTP encouraged copying images into directories until paths happened to work. M31V treated assets as build inputs referenced by content or templates.
The build validated each reference, generated appropriate derivatives, fingerprinted produced files, and rewrote URLs in the candidate output. Unreferenced source assets produced a report rather than being published automatically.
Image metadata included intrinsic dimensions so pages reserved space. The build recorded source ownership and alternate text policy. Large images triggered a budget warning before they became permanent transfer cost.
Fingerprinting allowed long cache lifetimes because changing content produced a new path. HTML and feed documents used shorter cache policy so activation could move readers to the current release.
The asset pipeline remained intentionally modest. It solved publication identity, validation, and reasonable derivatives. It did not become a general media-management system.
FTP was a transport, not a release protocol
Uploading files was not inherently wrong. The problem was activating them individually in the live directory while transfer was still in progress.
M31V could still use a simple file transport to place a complete artifact on the host. It uploaded into a new release directory, verified count and checksums, ran smoke checks against that directory, and then changed one active pointer.
build
upload to releases/build-142
verify build-142
activate build-142
retain build-141 for rollbackA failed upload left the active release untouched. A failed verification prevented activation. Rollback changed the pointer to the previous verified directory rather than trying to reconstruct old files through reverse uploads.
The architecture did not ban FTP by name. It stopped treating a sequence of mutable file writes as an atomic publish.
A full CMS could have provided authoring forms, permissions, scheduling, media management, and dynamic rendering. It would also introduce application upgrades, database backup, plugin compatibility, runtime query behavior, and a larger security surface.
For M31V's small editorial model, those costs did not buy necessary capabilities. The file system plus version control handled source history. The validator handled schema rules. The build handled derived surfaces. Artifact activation handled release consistency.
This was not a permanent declaration. If the publication later needed collaborative workflow, nontechnical visual editing, high-volume media, or per-request content decisions, the balance could change.
I documented the triggers that would reopen the decision. “We chose files once” was not an architecture argument for all future states.
The narrow system earned its place by matching current requirements and preserving migration options.
Editing ergonomics received real work
File-based publishing often transfers tool cost from developers to writers. M31V added small commands around actual editorial tasks:
new article
validate one source
preview candidate
list scheduled work
check links
explain generated pathsThe new-article command created a valid minimal header with a unique proposed slug. Validation ran quickly on changed content before the full build. Error output used relative paths and direct explanations rather than stack traces.
Preview watched source during writing for speed, while a complete candidate remained required for approval. The distinction between quick working preview and release preview was explicit.
Documentation began with “publish an article” rather than installation internals. Setup scripts checked runtime and dependency versions.
The system stayed small by making common work easy, not by asking writers to memorize its implementation.
Content relationships were checked before output
Author pages and topic archives depended on identifiers declared in separate files. An author rename could change a label without changing the stable ID. Deleting a topic required either reassigning its articles or intentionally redirecting its archive.
The graph validation found orphan relationships before any template rendered. It also checked canonical paths across articles, archives, and redirects as one namespace.
A static generator can emit broken links extremely quickly. Building ahead of time is valuable only if the build uses its complete view to reject inconsistency.
I added reachability checks from primary archives so an accidentally unpublished article did not become a valid but undiscoverable page. Intentional unlisted pages carried explicit metadata.
The graph supplied a global integrity pass that individual CMS form submissions or FTP files might not naturally provide.
The publication became a coherent set of identifiers, not a bag of pages.
Newsletter signup and later small interactive capabilities did not require making every article request dynamic. They used narrow services behind explicit forms, while the reading surface remained generated.
If the service failed, articles still loaded. The form displayed a scoped failure and preserved input according to its own policy. The publication build did not need database connectivity to render an archive.
This separation reduced shared failure domains. It also meant interactive endpoints needed their own deployment, security, and monitoring rather than hiding inside a “static” label.
I avoided claiming the site had no server. It had a simpler public content runtime and a few narrowly owned dynamic boundaries.
The architecture let each capability pay for the infrastructure it required instead of turning the whole publication into one application process.
Backups followed source and release separately
Version control preserved source content and build code. It did not automatically preserve deployed artifacts, secrets, external signup data, or every dependency needed to rebuild years later.
M31V retained recent verified release directories for immediate rollback and archived important build manifests. Source history remained the long-term editorial record. Dynamic service data followed its own backup policy.
I tested restoration as two tasks:
- Restore a known release quickly from an existing artifact.
- Rebuild a release from source and documented tool versions.
The first supported incidents. The second tested whether the repository remained a meaningful source of truth.
Separating them avoided a common assumption that “it's in Git” answers every recovery question.
M31V's design favored a small group comfortable with text files and review through version control. It did not offer real-time coauthoring, granular browser-based permissions, or nontechnical layout construction.
Build time grew with the complete content graph, though incremental authoring preview hid some of that cost. Scheduled publishing depended on a trusted build trigger. A broken build pipeline could delay publication even while old articles stayed available.
File merges could still be difficult for concurrent edits to the same source. Media organization remained more manual than a dedicated library. Validation rules required maintenance as editorial policy changed.
Naming those limits kept the choice honest. A narrow system is only simpler if its users are not forced to recreate missing capabilities through undocumented workarounds.
The project periodically reviewed whether the requirement list still matched reality.
The middle was a deliberate product
A full CMS was too much for M31V's actual workflow. Raw FTP was too little for its release and consistency requirements. The file-based build occupied the middle by taking responsibility for the parts that mattered:
portable source
validated content graph
complete candidate artifact
artifact-based preview
atomic activation
retained rollback
release provenanceIt did not win by having fewer moving parts in every sense. The validator, generator, asset pipeline, and deployment script were real software. They stayed narrow enough that each existed to preserve a named publishing guarantee.
The project taught me to stop comparing architectures by nouns. “CMS,” “static site,” and “FTP” each cover many implementations. The useful comparison is which system owns authoring constraints, derived views, preview fidelity, activation, recovery, and long-term identifiers.
M31V chose files as source and artifacts as releases. Everything valuable came from taking both choices seriously.
The strongest signal was recovery. A malformed article stopped before upload. An incomplete transfer never became active. A bad candidate could be replaced by the previous verified artifact without reversing dozens of individual files. Source history explained what changed, while the release manifest explained what actually reached the host.
That was the capability neither extreme had supplied in the original prototypes. The CMS trial concentrated more runtime and workflow than the publication needed. FTP moved bytes faithfully but knew nothing about a coherent release. The middle architecture made publication itself—the transition from reviewed source to one recoverable public state—its central operation.