Zero-downtime deployments on a single VPS: releases, migrations and instant rollbacks
You don't need Kubernetes to deploy safely. This is the release process we use for our own platform: versioned builds, atomic switches, backwards-compatible migrations, automatic backups and rollbacks in seconds.
9 min readRandiv Costa

You don't need Kubernetes to deploy safely. For a lot of businesses (a company website, an internal platform, a SaaS product in its first years) one well-run virtual private server (VPS) is cheaper and simpler, and plenty reliable. What makes a deployment safe isn't the platform. It's the process.
This post walks through the release process we use for our own website and operations platform. Every deployment is backed up and health-checked, and it can be rolled back in seconds. Users barely notice it happening.
Key takeaways
- Build on your machine or in CI, never on the server. Ship a finished, versioned package, so the server only unpacks and switches.
- Keep every release in its own folder and point a
currentsymlink at the live one. Switching versions, forwards or backwards, then becomes one atomic rename. - Write migrations in two phases (expand, then contract), so the previous version of your code still works on the new database schema. That's what makes rollback safe.
- Back up the database before every migration. It takes seconds and turns a disaster into an inconvenience.
- Check health after every switch, and switch back automatically if a check fails.
- Lock the server down: run each app as its own unprivileged user with systemd hardening, keep secrets in root-owned files outside the code, and accept web traffic only from your CDN.
Is a single VPS really enough?
For many applications, yes. A modest VPS with 2–4 CPU cores and 4–8 GB of memory can serve a busy business website and its back-office tools comfortably, especially behind a CDN like Cloudflare that serves static files and absorbs traffic spikes.
What you give up is automatic failover: if the machine itself goes down, so does the site until it's restored. For most small and medium businesses that's an acceptable trade for a fraction of the cost and complexity, provided you have good backups, monitoring and a tested way to rebuild. The goal of this post is to make the far more common risk, a bad deployment, a non-event.
Step 1: build somewhere else and ship a package
Building on the production server is tempting and causes problems. Installing dependencies and compiling a modern JavaScript app can need gigabytes of memory, which competes with the live site for the same machine. It also means production runs whatever happened to download that day.
Instead, each release is:
- Tagged in git, such as
v2026.11.01-2, and built only from a clean, committed tree that has passed the tests. - Built on a workstation or CI runner: the API bundle with only the dependencies it actually uses, the website's production build, and the dashboard's static files.
- Packed into a single tarball with a small manifest: the tag, the commit and the latest database migration it expects.
The server never runs a package manager or a compiler. Deploying the same tarball twice gives the same result every time.
Step 2: one folder per release, and a symlink
On the server, each release is unpacked into its own folder, and a symlink called current points at the live one:
/srv/myapp/
├── releases/
│ ├── v2026.10.28-1/
│ ├── v2026.11.01-1/
│ └── v2026.11.01-2/
├── current -> releases/v2026.11.01-2
├── shared/ # files that survive releases (caches, uploads)
└── backups/
The services always run from current. Releasing means pointing the symlink somewhere else and restarting. Rolling back means pointing it back.
One detail matters: make the switch atomic. Running ln -sfn directly on current removes the old link and then creates the new one, so for a moment there is no current at all. Instead, create the new link under a temporary name and rename it over the old one. A rename is a single atomic operation on Linux:
ln -sfn "releases/$TAG" current.next
mv -T current.next current # atomic: current is never missing
systemctl restart myapp-api
Keep the last five or so releases, so a rollback never needs a rebuild, and delete older ones automatically.
Step 3: run each app as a locked-down service
Run each application under systemd as its own system user, with no login shell and permission to read only its own release. systemd can restart it if it crashes, collect its logs, and, with a few lines of configuration, sandbox it:
[Service]
User=myapp
WorkingDirectory=/srv/myapp/current/api
EnvironmentFile=/etc/myapp/api.env
ExecStart=/usr/bin/node dist/server.mjs
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/myapp/shared
MemoryHigh=400M
ProtectSystem=strict makes the whole filesystem read-only for the service except the paths you list. If the app were ever compromised, it couldn't modify its own code or anything else on the server. MemoryHigh stops one app from starving its neighbours on a shared machine.
Secrets (database passwords, API keys) live in root-owned files such as /etc/myapp/api.env that only the app's group can read. They are never in the repository, never in the release tarball and never in the command line, where other users could see them in the process list.
Step 4: migrations that make rollback safe
Code is easy to roll back. Data isn't. If version 2 renames a database column and you roll back to version 1, version 1 looks for the old column and crashes. So "just roll back" only works if the previous release still works on the new schema.
The technique is called expand and contract. Split every breaking change into steps that are each backwards-compatible:
| Release | Database change | Code |
|---|---|---|
| 1. Expand | Add the new column full_name, nullable | Write to both name and full_name. Read name |
| 2. Migrate | Copy existing name values into full_name | Read full_name |
| 3. Contract | Drop name, once nothing uses it | Only full_name |
At every step, the release before it still works, so you can roll back at any point. It costs a little patience in exchange for never having to restore a database because of a deployment.
A few more rules worth following:
- Migrations only move forward. Don't rely on "down" migrations in production. Fix forward with a new migration.
- Run migrations as a separate, more privileged database user. The app's own user should only be able to read and write data, not change the schema or drop tables.
- Use a migration lock, so two deployments can't migrate at the same time.
Step 5: back up before you touch anything
Every deployment starts with a database backup, before any migration runs. With PostgreSQL, a compressed dump of a small or medium database takes seconds:
pg_dump --format=custom --file="backups/pre-deploy-$TAG.dump" myapp
Keep the most recent dozen or so, alongside a nightly backup, and test a restore occasionally: a backup you've never restored is only a hope. For real resilience, also copy the nightly backups off the server, encrypted, to object storage in another location. See the PostgreSQL documentation on backups for the options.
Step 6: check health, and switch back automatically
After switching current and restarting, don't assume it worked. Check it:
- The API's readiness endpoint, such as
/readyz, which confirms the app has started and can reach the database. - The website's home page, through the app's local port.
- Each public hostname through the web server, to catch configuration problems between Nginx and the apps.
If any check fails within a set time, the deploy script points current back at the previous release, restarts, and exits with an error. Because the migrations were backwards-compatible, the previous release runs happily on the new schema. The bad release gets investigated, not live traffic.
Practise this. A rollback drill, deploying and then deliberately rolling back, should be boring. If it isn't, you've found the problem on a quiet afternoon rather than during an incident.
Step 7: put the server behind a CDN, and only the CDN
Putting Cloudflare (or a similar CDN) in front of your server gives you TLS at the edge, caching, and protection against floods of traffic. But that only helps if attackers can't simply go around it to your server's IP address.
- Allow web traffic only from the CDN's published IP ranges and refuse everything else. Anyone who discovers your IP and connects directly gets a 403.
- Restore the real visitor IP from the CDN's header (for Cloudflare,
CF-Connecting-IP) so your logs and rate limits see real visitors rather than the CDN. - Use a real certificate on the server too, and set the CDN to verify it ("Full (strict)" on Cloudflare), so traffic is encrypted all the way to your server.
One trap with Nginx: once the real IP has been restored, allow/deny rules check the visitor's address, not the CDN's. To check that a request really came through the CDN, match on $realip_remote_addr, the original connecting address, for example with a geo block.
What about the last second?
Restarting a single Node.js process takes around a second, and during that second requests can fail. For a company website or internal tool, a one-second blip during a planned release is usually fine. A CDN can even serve cached pages through it.
If you need truly zero downtime, run two copies of the app on different ports. Start the new release on the idle port, check it's healthy, then point Nginx at it and reload. An Nginx reload is graceful, so in-flight requests finish on the old process. Then stop the old copy. It's the same release-folder approach, with one more moving part.
A pre-deploy checklist
- The release is built from a tagged commit that passed the tests.
- Any migration is backwards-compatible with the release currently live.
- A database backup is taken automatically before migrating.
- Health checks cover the API, the website and every public hostname.
- A failed check switches back to the previous release automatically.
- Secrets are in root-owned env files, not in the code or the package.
- Services run as unprivileged users with systemd sandboxing.
- The origin accepts traffic only from your CDN.
- You've practised a rollback recently.
Frequently asked questions
What is a zero-downtime deployment?
It's a way of releasing a new version of an application without users experiencing errors or an outage. The new version is prepared and checked before traffic moves to it, and traffic can move back if something's wrong.
How do I roll back a deployment on a VPS?
Keep previous releases on the server in their own folders and run the app from a current symlink. Rolling back means pointing the symlink at the previous folder and restarting, which takes seconds, provided your database migrations are backwards-compatible.
Should database migrations run before or after deploying the code?
Before switching to the new code, and after taking a backup. Because each migration is written to be compatible with the previous release (expand and contract), the old code keeps working until the switch, and after it if you need to roll back.
Do I need Docker to deploy safely?
No. Docker is useful, but the safety comes from the process: immutable releases, atomic switching, backups, health checks and backwards-compatible migrations. All of these work just as well with systemd on a plain Linux server.
Need help shipping?
This is how we deploy our own website and platform, and the software we build for clients. If you're building something new or your current deployments make everyone nervous, take a look at our custom software and web and mobile app services, or get in touch.
More from the blog: how to add an AI chatbot to your website safely, and a practical guide to automating business workflows.