Stateless App Processes Plus Stateful Disks
Keep workers disposable and put sessions, uploads, and Postgres on named disks so a restart or deploy never depends on files left in the process tree.
Netbay Infrastructure Team
Netbay Engineering
On this page
A process is stateless when you can kill it and start another one without losing a user session, an uploaded invoice, or a row that was committed a second ago. A disk is stateful when it is the only place those things live, it is mounted at a known path, and it is the thing you back up. Mixing the two — sessions in /tmp, uploads inside the git checkout, SQLite on the same filesystem as the OS — is how a deploy becomes data loss. On one VPS you still need both: disposable workers and named, durable directories. The architecture is the line between them.
What belongs in the process, and what does not
The process may hold an in-memory cache that can be rebuilt, an open database connection, and the code of the current release. It may not hold the source of truth for a login, a file the user uploaded, or a job that must run exactly once. Those go to Redis, to /var/lib/app/uploads, and to Postgres. If you cannot answer "where does this byte live after SIGKILL" in one path, the byte is in the wrong place.
Releases go under /srv/app/releases and are never written after they are unpacked. The running unit points at /srv/app/current, a symlink. Uploads, image variants, and export files go under /var/lib/app. Postgres uses /var/lib/postgresql. Redis uses /var/lib/redis. Logs go to journald, not to a file next to the code. That split is the whole design. It survives a blue-green switch, a botched deploy, and a process crash, because none of those events touch the data directories.
Make the app refuse local state
Code has to cooperate. A session middleware that falls back to the filesystem will do it quietly in production the day Redis is down. Fail instead. An upload handler that writes next to the source file will fill the release disk. Write to a configured directory and nowhere else.
// server.js — refuse to boot without backing stores
var http = require("http");
var fs = require("fs");
var path = require("path");
var bind = process.env.BIND || "127.0.0.1";
var port = Number(process.env.PORT || 0);
var uploadRoot = process.env.UPLOAD_ROOT;
var redisUrl = process.env.REDIS_URL;
var databaseUrl = process.env.DATABASE_URL;
if (!port || !uploadRoot || !redisUrl || !databaseUrl) {
console.error("missing BIND/PORT/UPLOAD_ROOT/REDIS_URL/DATABASE_URL");
process.exit(78);
}
fs.mkdirSync(uploadRoot, { recursive: true });
http.createServer(function (req, res) {
if (req.url === "/healthz") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
return;
}
if (req.method === "POST" && req.url === "/upload") {
var dest = path.join(uploadRoot, String(Date.now()));
var out = fs.createWriteStream(dest);
req.pipe(out);
out.on("finish", function () {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ stored: dest }));
});
return;
}
res.writeHead(404);
res.end();
}).listen(port, bind);Sessions go to Redis with a short TTL. If Redis is down, logins fail closed. That is better than succeeding against a file in /tmp that vanishes on reboot. Jobs that must run once go to a table in Postgres with a unique constraint, not to a memory queue inside the web process.
Disks, mounts, and what you back up
On a typical Netbay VPS the OS and the data share High-Speed SSD storage. You can still name the state. Create /var/lib/app, set owner app:app, and put uploads there. If the plan later includes a second volume, mount it at /var/lib/app so the path never changes. Postgres already lives at /var/lib/postgresql; do not relocate it into the app tree. Back up those two trees plus /etc/app. Do not back up /srv/app/releases; that is a build artifact and it is in git.
# layout and permissions on first boot
install -d -o app -g app -m 0750 /var/lib/app/uploads
install -d -o app -g app -m 0750 /srv/app/releases
ln -sfn /srv/app/releases/20260419T120000 /srv/app/current
# what restic includes (data) vs excludes (code)
restic backup /var/lib/app /var/lib/postgresql /etc/app
# never: restic backup /srv/appA deploy then becomes: unpack a new release, run migrations against Postgres, flip the symlink, SIGTERM the workers. Uploads and rows stay put. If the new release is bad, flip the symlink back and restart. That rollback only works if the process did not write anything precious into the old tree. Treat /tmp and /srv/app as scratch. Treat /var/lib as the product.
How you know the split is holding
The test is operational, not rhetorical. Deploy twice in a row without touching /var/lib/app or /var/lib/postgresql. Kill the app unit with systemctl kill -s SIGKILL and confirm sessions still exist in Redis and rows still exist in Postgres. Fill the release disk with a dummy file and confirm uploads still succeed because they never used that disk. If any of those tests fail, the process is still stateful and you have more moving to do. Watch inode usage as well as bytes: thousands of tiny uploads can exhaust inodes on a volume that still looks empty in df -h.
Workers should also refuse to start if UPLOAD_ROOT is missing or not writable by the app user. A boot that silently writes to the working directory is how state creeps back in after a well-meaning refactor. Log the resolved paths once at start — not the secrets, just the directories — so journalctl -u app tells you which trees this process believes are durable. That line in the journal is the architecture, written down.
You can stand up this split on a Netbay Ubuntu 24.04 instance in Lucknow DC01 in under 60 seconds and keep the disks honest from the first deploy — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance