Applying Twelve-Factor Discipline on One Box
Run a twelve-factor app on a single VPS: env config, stateless processes, port bind, stdout logs, and disposability without pretending you have a fleet.
Netbay Developer Relations
Netbay Engineering
On this page
The twelve-factor manifesto was written for apps that run on a platform. A single VPS is not a platform. It is a Linux box with systemd, a disk, and a public IP in Lucknow DC01. That is not an excuse to ignore the factors that still pay rent: config in the environment, stateless processes, a strict port bind, logs as event streams, and disposability. The factors that assume a build-pack marketplace or a dyno scheduler can wait. The ones that keep a Node or Python service restartable at 2 a.m. cannot.
Keep the factors that survive contact with one host
Config in the environment means the same release artifact runs in staging and production with different EnvironmentFile contents. You do not edit settings.py on the box. You do not keep DATABASE_URL in the git repo. A file under /etc/app/env, mode 0640, owned by root:app, is the whole config store. systemd reads it; the process inherits it; a deploy never overwrites it.
Stateless processes mean the app can be killed and replaced without losing a checkout or a session. Store sessions in Redis or in a signed cookie. Store uploads on a dedicated directory that is not inside the release tree. Port binding means the app listens on a port from the environment, usually 127.0.0.1:3000, and the reverse proxy is a different process. Logs as event streams mean the app writes to stdout and stderr; journald collects them. Disposability means stop is fast (TimeoutStopSec=20) and start is fast (no compile step on boot).
Config, process, and port as systemd, not folklore
A unit file is the smallest twelve-factor runtime that does not require a platform. EnvironmentFile is config. ExecStart is the process. The PORT and BIND variables are the port bind. StandardOutput=journal is the log stream. KillSignal=SIGTERM plus TimeoutStopSec is disposability.
# /etc/systemd/system/app.service
[Unit]
Description=Twelve-factor app
After=network.target redis-server.service postgresql.service
[Service]
User=app
WorkingDirectory=/srv/app/current
EnvironmentFile=/etc/app/env
ExecStart=/usr/bin/node dist/server.js
Restart=on-failure
RestartSec=2
TimeoutStopSec=20
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetThe env file stays out of the release:
# /etc/app/env (mode 0640, root:app)
NODE_ENV=production
BIND=127.0.0.1
PORT=3000
DATABASE_URL=postgres://app:secret@127.0.0.1:5432/app
REDIS_URL=redis://127.0.0.1:6379/0
LOG_LEVEL=infoThe app must refuse to boot if a required variable is missing. Silent defaults are how staging secrets leak into production and how production points at a developer laptop. Check them at process start, log the names of the missing keys, and exit 78. Do not log the values.
Factors to adapt, and factors to skip
Build and release: on one box, a release is a directory under /srv/app/releases/TIMESTAMP plus a symlink /srv/app/current. A deploy copies the artifact, flips the symlink, and restarts the unit. Backing services: treat Postgres and Redis as attached resources even when they run locally. The URL in the env file is the attachment. If you later move Postgres to a second VPS, the app does not change; the env file does. Concurrency: scale by process count on the same host (more workers in the unit, or a second unit) until the CPU or RAM on the Xeon Platinum core budget is gone. Then you buy a larger plan, not a scheduler.
Admin processes: run migrations as oneshot units or as a manual sudo -u app command from /srv/app/current, never as a side effect of web start. Dev/prod parity: use the same Ubuntu, the same systemd unit, and the same env keys locally in containers or on a small Netbay instance. Do not invent a second framework for production. The factors that assume a build-pack catalog, a dyno grid, or a managed cluster are not Netbay features and they are not required to ship. Skip them without guilt.
Logs stay in journald until they need to leave the box. journalctl -u app -f is the tail. A timer that ships JSON to an off-box store is a later ops layer, not a reason to write log files inside the release tree. Disposability is the factor people skip and then pay for: a graceful SIGTERM that finishes in-flight requests in under 20 seconds lets you deploy without dropping nginx. If your framework ignores SIGTERM, fix that before you add features.
You can provision Ubuntu 24.04 on Netbay in under 60 seconds, drop an EnvironmentFile and a unit next to it, and practice twelve-factor without a platform — 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