Skip to main content
Deployments & App Management

How we do atomic app deploys

Deploying without docker, and how we swap whole app directories atomically using nothing but systemd.

Ruslan Gainutdinov

WARNING: Pre-release feature, opt-in by setting DEPLOY_ISOLATED=1 in your app env

Every deploy on DollarDeploy ships a new build to your server, unpacks it, and restarts the app. The tricky part is the moment in between: while the new files land, an old version of the app is often still running against the old files. Do that without thinking and you get a process reading half of one build and half of another.

Here is how we made this deployment atomic — and why we did it with plain systemd instead of using a container runtime (Docker).

The problem

You can't merge a node_modules directory with a previous build!

14:42:24 Unzipping /home/ubuntu/myapp/app.zip
14:42:24 error: cannot delete old /home/ubuntu/myapp/.output/server/node_modules/detect-libc Is a directory
14:42:24 error: cannot delete old /home/ubuntu/myapp/.output/server/node_modules/@sentry/core Is a directory

The initial approach is the obvious one: download the build, unzip it over the app directory, restart. unzip -o overwrites changed files in place.

That works right up until a dependency tree changes in an incompatible way. In some cases, node_modules is not a folder you can merge. When you upgrade or remove a package, files disappear, move, and get replaced, sometimes by the symlink to the other place or package.

Overlaying a new build on top of the old one leaves orphaned files behind — a module directory that no longer exists in your package.json but still sits on disk, a native binary from the previous version next to the new one. The result is a dependency tree that never existed in any build you actually made. The same is true for .next, .output, dist, and every other build directory: they are snapshots, not a working dir you can write anything to.

Merging is wrong. These directories have to be replaced as a whole. And you have to replace them without removing it while the running app is still serving requests.

The approach

Make a pristine copy per deploy, bind-mounted read-only into the new app

Each deploy now extracts into its own directory, prefixed by the deploy ID:

~/.apps/<app>/<deploy-id>/

That is a new copy of exactly what the build produced. Nothing merges into it and nothing else writes to it.

Then, instead of pointing the app at that directory, we bind-mount each top-level build directory from it into the app's own directory — read-only — inside the systemd unit:

BindReadOnlyPaths=${HOME}/.apps/myapp/<deploy-id>/node_modules:${HOME}/myapp/node_modules

Because systemd runs every service in its own mount namespace, this bind is private to the running app. The process sees an immutable, complete copy of its build. Because a new deploy writes a brand new pristine directory rather than overwriting the old one, whatever files the running process still has open stay intact — they are never mutated out from under it. The restart then swaps the whole build directory at once instead of merging file by file. No process ever sees a half-swapped node_modules.

The one exception is .next for Next.js apps. Next.js writes its incremental cache back into .next/cache at runtime, so that one is mounted writable rather than read-only. Everything else the app should never be changing into stays read-only.

App can still create folders in the same directory the app is working from.

We keep the last few deploy directories around instead of deleting them immediately. That is what makes rollback cheap: the previous build is still sitting on disk, pristine, ready to be mounted again.

The limitation

Changes take effect on the next restart

Bind mounts cover whole directories, and they are wired up when the unit starts — so a build directory is read-only for the life of the process. Loose files we add on top of the build (a config file, a start script, the .env) sit at the root and merge in place, so the app sees them straight away. Files that belong inside a build directory — say something you drop into node_modules or public — are copied into the pristine per-deploy copy too, so they show up in both the running unit and the folder on disk. The catch: because the mount is fixed at start, that only lands on the next start. You cannot poke a file into a read-only build directory and have the live process pick it up mid-run — deploy again and it is there, in both views. In practice that is fine: a deploy is a restart.

Why it is good

Two things we care about:

First, it is boring. There is no overlay filesystem to reason about, no container image to rebuild, no symlink flip that leaves you guessing which release is live. It is unzip into a directory and a few lines of systemd unit config. systemd already gives us the mount namespace, the read-only enforcement, and the process supervision. We did not have to build any of that.

Second — and this is the part we did on purpose — the app directory on your server is a real, up-to-date folder, not a virtual, inaccessible container. If you SSH in and cd into your app, you see the actual current build: real files, on disk, exactly what is running. The read-only bind mount only shadows those directories inside the service's private namespace. It does not replace the folder you poke around in.

That matters when something is off at 1am and you want to read a file, check a version, or diff two builds by hand. A lot of "atomic deploy" schemes give the running process a clean view but leave you staring at a symlink farm or an anonymous overlay when you try to inspect it yourself. We wanted the guarantee for the process and a plain directory for the human.

Summary

  • Build directories like node_modules are replaced as a whole, never merged.
  • Each deploy gets a pristine copy; systemd bind-mounts it read-only into the running unit.
  • A new deploy never overwrites the running build, so the old process keeps a complete copy until it stops — the swap happens whole, not file by file.
  • Root-level files still merge in place — that is the deliberate limitation.
  • The folder on disk stays real and browsable, so you can always look for yourself.

Paste a GitHub repo, get a real URL, and every deploy after that is atomic without you configuring anything. Deploy in under 5 minutes at dollardeploy.com.

Updated on Aug 6, 2026