Self-Hosting a Node and PostgreSQL Service With nginx and systemd on Ubuntu
The deployment behind print.rukon.dev: one origin for a PWA and API, a hardened systemd unit, atomic releases, nginx header gotchas and a backup design.
What is being hosted, and why one origin
The RPOMS Print Engine is a label-printing PWA with an optional backend: Google sign-in, workstation provisioning and configuration sync, on Node.js 24 with PostgreSQL. It has been live since 29 September 2026 at print.rukon.dev, on a self-hosted Ubuntu server behind nginx, with the API as a systemd service. Why this app runs on my own server rather than a managed platform is discussed in choosing between Vercel and a self-hosted Ubuntu server. This article is the how.
The central decision is a single origin. nginx serves the static app and proxies /api/ to the Node process on loopback. PostgreSQL listens on localhost only.
- 01https://print.rukon.dev/ -> static files from the current release directory
- 02https://print.rukon.dev/api/... -> Node API on 127.0.0.1:8787
- 03Node API -> PostgreSQL on 127.0.0.1:5432, never reachable from a browser
With one origin, the browser needs no CORS, the session cookie can stay SameSite=Strict, and the Content Security Policy can say connect-src 'self'. Two origins would have meant loosening all three.
A systemd unit that assumes the process could be compromised
The API runs as a dedicated rpoms system user, with its secrets in a .env file readable only by that user (mode 0600), loaded with EnvironmentFile. The unit restarts the service after three seconds if it dies, and starts after PostgreSQL. Most of the file is hardening: the process needs to read its own directory and talk to loopback, and nothing else.
[Service]
User=rpoms
EnvironmentFile=/opt/rpoms-print-engine/server/.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
SystemCallFilter=@system-service
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
UMask=0077The deploy script has to restart the service, and it runs as the unprivileged user. Rather than give that user general sudo, a sudoers line allows exactly three commands: systemctl restart, start and stop for this one unit.
Refuse to start when misconfigured
A misconfigured server that starts anyway is worse than one that does not start. With NODE_ENV=production the server checks its configuration at startup and exits with a clear message if any of these hold:
- The public origin is not
https://. - The session secret is shorter than 32 characters.
- There is no
DATABASE_URL. The in-memory store used in development loses everything on restart. - Secure cookies have been switched off.
- The bind address is not loopback.
Each of these is a setting that is legitimate in development and dangerous in production, and each would otherwise fail silently. It also pings the database at start, logs one line per request to journald, cleans up expired sessions hourly and shuts down gracefully on SIGTERM.
Deploying: migrate, test, build, health-check, swap a symlink
The deploy script is meant to be re-run safely. It pulls the branch fast-forward only, then:
- Installs the server's dependencies and applies database migrations.
- Installs the app's dependencies and runs the label template freeze test on its own; a failing test stops the deploy.
- Builds the server and the app.
- Restarts the API and polls
/api/healthfor up to ten seconds; if it never answers, it prints the last 30 journal lines and fails. - Copies the built app into a new release directory named after the commit and time.
- Points a temporary symlink at it and renames it over
currentin one step. - Deletes all but the five most recent releases.
The rename matters. nginx serves from /var/www/rpoms-print/current. Copying files into a live directory means a workstation loading during the copy can get a new index.html with old assets. ln -sfn to a temporary name followed by mv -T replaces the symlink atomically, so a request sees either the old release or the new one. Old releases stay on disk, so rolling back is pointing the symlink back.
Migrations are plain SQL files applied in name order by a small runner. Each file runs in a transaction and is recorded in a schema_migrations table, so it applies once. The files are also written to be idempotent, so a database created before the table existed is brought up to date safely. CI applies all migrations twice against a Postgres 16 service to prove that. The freeze test that guards the deploy is described in freezing a label design in CI.
nginx: two gotchas worth knowing
The first is cache headers for a PWA. The service worker script, index.html, the manifest and Workbox's runtime must be revalidated on every load, or workstations can stay on an old version indefinitely. Vite's hashed assets under /assets/ never change, so they are cached for a year as immutable.
The second is easy to miss. In nginx, an add_header inside a location block replaces every add_header inherited from the server block; it does not add to them. So the moment /sw.js got its own Cache-Control header, it silently lost the Content Security Policy, HSTS and every other security header. The fix is to keep the security headers in a snippet and include it in every location that sets its own headers:
location = /sw.js {
include /etc/nginx/snippets/rpoms-print-headers.conf;
add_header Cache-Control "no-cache" always;
}
location /assets/ {
include /etc/nginx/snippets/rpoms-print-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}A unit test in the repository checks that every security header appears, verbatim, in both the site file and the snippet. The header set itself is the subject of a separate article on securing a Web Bluetooth PWA.
The rest of the site is routine but deliberate: TLS 1.2 and 1.3 only, port 80 redirected to HTTPS, rate limits of 10 requests a minute per IP on sign-in and provisioning and 60 a second on the rest of the API, a 6 MB body limit, and no dotfiles or source maps served. Order matters on first setup, and the setup guide was corrected for it: install the systemd unit before the first deploy, and issue the TLS certificate before enabling the site, because nginx -t fails on a missing certificate path.
Backups, and a restore that has not yet been tested
A nightly cron job runs a backup script as the service user. Each backup is a dated directory, created with a restrictive umask because it contains secrets, holding:
- A custom-format
pg_dumpof the database. - The central templates and rules exported as plain JSON, readable without PostgreSQL.
- The server's
.env, the nginx site and the systemd unit. - The git commit that was deployed.
- A
SHA256SUMSfile over all of it.
Directories older than 30 days are deleted.
The restore script has two modes. --verify checks the checksums, creates a scratch database, restores the dump into it, prints row counts for the main tables and the configuration version, and drops the scratch database on exit. It is safe to run at any time, and it is the only honest way to know a backup is usable. That is also why the database role has CREATEDB: verify needs to create the scratch database. --restore replaces the real database, but only after you type its name. It stops the API, terminates remaining connections, recreates the database, restores, re-applies any migrations newer than the backup, starts the API and checks health.
One property makes a server restore less frightening than usual: the workstations are local-first. Each keeps its own templates, rules, session and unsynced history, and resumes syncing against the restored server. A workstation provisioned after the backup was taken is unknown to the restored database; it signs in once and is provisioned again. How the device tokens and the sync work is covered in the provisioning article and the conflict policy article.
Health and outages
/api/health returns the version, whether the store is PostgreSQL, whether Google sign-in is configured, and uptime, and returns 503 when the database does not answer. That one endpoint is what the deploy script waits on. Logs are in journald for the API and in two nginx log files for the edge.
An outage of the whole server does not stop printing. Provisioned workstations boot from their service worker cache and IndexedDB, print, and queue their history until the server is back. For this app, that matters more than any of the hosting details above.
Keep reading
Vercel or a Self-Hosted Ubuntu Box? How I Chose Hosting App by App
Eight deployed apps, three hosting shapes: Vercel for pages and serverless APIs, a self-hosted Ubuntu box for a stateful API, and a hybrid for a model Vercel can't run.
Google Sign-In for Shared Workstations: PKCE, Pending Users and Revocable Device Tokens
Google OIDC with PKCE on a framework-free Node server, users keyed by sub, accounts that start pending, and workstations provisioned with hashed, revocable device tokens that keep working offline.
Freezing a Label Design in CI by Hashing Its Rendered Raster
A Vitest test that hashes each label template's JSON and its exact 1-bit print raster, so any change that would move a single printed dot fails the build and the deploy.