Get in touch →
← All posts
Operations2026.07.01

Two Deploy Paths, Two Artifacts

There are two shapes of deployment running in this infrastructure, and for a while I felt a mild pressure to collapse them into one. A static site (this one, along with a couple of others) deploys via a direct file sync straight to wherever it’s served: build the output, rsync it to the target, done. A containerized app deploys through the more familiar path: build an image, push it to a registry, then pull or run that image on the target. Two different shapes, two different mechanisms, and I’ve kept them that way on purpose rather than forcing both artifact types through the same pipeline.

Why the pressure to unify exists

The pull toward “just have one deployment pipeline” is real and I understand where it comes from. One pipeline means one thing to maintain, one set of credentials and secrets to manage, one mental model for “how does a deploy happen here.” If everything goes through build-push-pull, then adding a new static site is just another entry in a system I already understand, instead of a second system I have to keep separately in my head. Consistency has genuine value, and a lot of platform engineering advice defaults to “pick one deployment shape and make everything fit it” for exactly that reason.

I looked at that option seriously: wrap the static site’s build output in a minimal container image (an nginx or Caddy base plus the built files), push it to the same registry every other app uses, and deploy it with the same pull-and-run mechanism. It would have worked. It also would have been pure overhead with nothing on the other side of the ledger.

Static content takes the direct sync path; containerized apps take the registry path.

Why a static site has no reason to touch a registry

A static site is a folder of files. It doesn’t need a runtime, it doesn’t need process supervision inside a container, it doesn’t need an image to hold layers of an OS and a web server just so an HTTP server can hand back HTML and assets that were already fully built at deploy time. The entire value a container gives you (packaging an application together with its runtime environment so it behaves identically wherever it runs) doesn’t apply when there’s no application behavior to package. There’s no runtime dependency drift to solve, because there’s no runtime. The “environment” a static site needs is “a web server exists and can read these files,” which is exactly as true before wrapping it in a container as after.

Once you notice that, routing static output through a container registry stops looking like consistency, and starts looking like an unnecessary round trip. Build the site, then build an image around the site, then push that image somewhere, then pull it back down, then start a container whose only job is to serve files that were sitting right there in the build output the whole time. Every one of those extra steps is something that can fail, something that takes time, and something that exists purely to satisfy the shape of a pipeline rather than an actual requirement of the artifact. The rsync path skips all of it: build the site, copy the files to where they’re served, done. Fewer moving parts, and a deploy that fails in fewer places because there are fewer places for it to fail in.

Why the container path still earns its complexity for actual apps

The containerized path isn’t complexity for its own sake: it’s solving a real problem that only exists once there’s an actual application with a runtime, dependencies, and behavior that needs to be identical across environments. Build produces an image that bundles the app with everything it needs to run. Pushing that image to a registry gives you a single addressable, versioned artifact: something you can point at by tag or digest and know exactly what’s running, roll back to a previous version by re-pulling an older tag, or run the identical thing across multiple hosts without re-running the build on each one. That pipeline is also the one that, at one point, tried skipping the redeploy for components that looked unchanged and quietly shipped nothing instead — part of why it now stays deliberately simple rather than clever. Pulling and running on the target is the part that actually needs container semantics: process isolation, a defined runtime environment, resource limits, restart policies.

None of that machinery buys anything for a folder of pre-built HTML and CSS. It’s not that the container path is wrong; it’s exactly right for the artifact it was designed for. The mismatch only appears when you try to force a second, fundamentally different artifact type through the same pipeline because having one pipeline felt tidier than having two.

What keeping them separate actually costs

I don’t think this is a free lunch, and it’s worth being specific about the cost rather than pretending there isn’t one. I do maintain two deploy mechanisms instead of one. That means two things to remember when something goes wrong, two sets of assumptions about what “deployed” means (a set of files updated on disk versus a container process restarted), and a small amount of duplicated tooling: the registry-based path and the rsync-based path don’t share much machinery, so improvements to one (better logging, better rollback, better failure notifications) don’t automatically carry over to the other.

In practice this hasn’t been a heavy cost, because the number of deploy shapes I need is small (two, not five) and each one is genuinely simple on its own. The static path is a build step and an rsync command. The container path is a build, a push, and a pull. Neither is trying to be clever or generic enough to absorb the other’s use case, so neither has grown the kind of accidental complexity that comes from a pipeline trying to be all things to all artifacts. If I ever had a third fundamentally different artifact type (a scheduled batch job, say, or something that needs to run on the edge rather than a fixed host) I’d want to think hard about whether it’s a third shape, or whether it’s the container path with a different trigger. But I wouldn’t default to forcing it into one of the two existing shapes just to avoid writing a third small pipeline.

The general shape of the decision

The rule I’ve ended up with, stated plainly: match the deployment mechanism to what the artifact actually is, not to what would make the tooling most uniform. A build artifact that’s just files wants a mechanism that moves files. A build artifact that’s a runtime-bundled application wants a mechanism designed for running images. Forcing the first through the second’s pipeline buys uniformity and nothing else, at the cost of extra steps, extra failure points, and an artifact wrapped in machinery it never needed. Uniformity is a nice property when it falls out of two things genuinely being the same shape. It’s a worse trade when it’s manufactured by pretending two different shapes are one.

Rollback looks different on each path, and that’s fine

One place the two paths genuinely diverge, and where I had to stop expecting them to feel symmetric, is rollback. On the container path, rollback is close to free: the registry already holds the previous image under its own tag or digest, so reverting is “run the old tag instead of the new one,” and the machinery that made deploy versioned and addressable makes rollback versioned and addressable for the same reason. On the static path, there’s no registry holding prior versions by default; the rsync target just has whatever was last synced to it. If I want the same easy rollback story for the static site, I have to build it separately: keep the previous build output around, or lean on the fact that the source is in version control and a rollback is “rebuild from an earlier commit and re-sync,” which costs a build cycle rather than being instantaneous.

I considered treating that gap as a reason to reconsider the whole split: if the container path’s registry gives you rollback for free, isn’t that a real advantage worth the overhead for every artifact, including static ones? I don’t think so, mainly because the static site’s deploy is fast and cheap enough (a build plus a sync) that “rebuild and re-sync an older commit” is a rollback path measured in the same rough ballpark of time as the registry-based one, without needing to carry registry infrastructure whose only job for this artifact would be storing something a git checkout already stores. The two paths don’t need identical rollback stories to both be good rollback stories for what they’re each deploying.

Observability is the other place they don’t match

The container path gives me a running process I can query: container logs, a restart count, a health check endpoint, resource usage over time. The static path, once the sync completes, is just files being served by a web server that isn’t specific to this site; there’s no per-deploy process to introspect, because there’s no per-deploy process at all. What I actually monitor differs accordingly: for the containerized app, I care whether the container is up, restarting, or unhealthy. For the static site, the meaningful signal is closer to “did the sync complete without error, and does the web server still respond”: a shallower but adequate check for an artifact that has no runtime behavior of its own to go wrong. Trying to bolt container-style observability onto the static path would mean manufacturing a process to observe just so the observability tooling would have something familiar to point at, which is the same kind of unnecessary machinery the deploy path itself avoided by not wrapping the site in a container in the first place.

← All posts