VPS Deployment Checklist: 24 Checks Before You Go Live

deploymentdevopsdockerself-hosting
VPS Deployment Checklist: 24 Checks Before You Go Live

A VPS deployment checklist should prove that users reach your app over HTTPS, administrators reach the server privately, you can name the running release, a stored backup restores, and an alert reaches a person. A successful deployment covers almost none of that, so run each check below and write down the result.

The output in this guide comes from a real run: the .NET and Postgres sample in Docker, a fresh Ubuntu 24.04 container, and this website for the TLS checks. Replace app.example.com and your-server-ip with your own values.

The Complete Checklist

Copy this into your repository and fill it in. A Markdown version of this page is available at /blog/vps-deployment-checklist.md.

Access

  • SSH accepts keys only, and root login is off
  • The provider firewall allows only 80, 443, and your admin path
  • A scan from outside the server shows only the expected ports
  • The deployment panel can't be reached publicly
  • You have logged in through the provider's recovery console

HTTPS and routing

  • HTTP redirects to HTTPS
  • The certificate is valid and you know its expiry date
  • No container publishes a port it doesn't need to
  • A record saved through the public domain survives a refresh

Secrets and platform

  • Secrets live outside Git, with separate values for separate purposes
  • The panel has two-factor authentication and runs a current release

Releases

  • Images are tagged by commit and the digest is recorded
  • The running app reports the commit you expected
  • You have redeployed the previous image
  • The previous image works with the current database schema

Health

  • Readiness fails when the database is down
  • The restart policy is set and tested with a real process exit
  • Container logs have a size limit

Data

  • Every durable store is listed, including uploaded files
  • A fresh backup exists outside the server
  • A stored backup restores and your data checks pass

Detection and maintenance

  • An outside monitor sent you a DOWN alert and an UP alert
  • Security updates install automatically and reboots have a plan
  • Someone owns a recurring review of disk, backups, and updates

How Do You Check Server Access?

Start with what SSH actually applies, because files in sshd_config.d can override the main config:

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication) '

A fresh openssh-server package on Ubuntu 24.04 prints this:

permitrootlogin without-password
passwordauthentication yes
kbdinteractiveauthentication no

Provider images often change these defaults, so check yours. After hardening, the same command should print:

permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no

Run sudo sshd -t before reloading SSH, keep your current session open, and test the new login from a second terminal.

How Do You Find Open Ports?

Check from inside the server first:

sudo ss -tlnp

0.0.0.0 or [::] in the address column means the service listens on every interface:

State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      128          0.0.0.0:22        0.0.0.0:*    users:(("sshd",pid=3984,fd=3))
LISTEN 0      128             [::]:22           [::]:*    users:(("sshd",pid=3984,fd=4))

A listening socket doesn't mean the port is reachable, so also scan from a machine outside your private network, on an IP address your SSH rule doesn't allow:

nmap -Pn -p 22,80,443,3000,5432,6379,8000 your-server-ip

Only 80 and 443 should show open. filtered means a firewall dropped the probe, which is what you want for everything else. closed means the probe reached the server and nothing is listening, so your provider firewall isn't covering that port.

This scan matters because of how Docker works. Docker's firewall documentation explains that published container ports skip UFW rules, so ufw status can look correct while a database port is open. Your provider's firewall sits outside the server, so Docker can't route around it. Docker bypasses UFW has the test results and fixes, and cloud firewall vs UFW covers the rules to use.

List what each container publishes:

docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES       PORTS
app-api-1   127.0.0.1:36507->8080/tcp
app-db-1    5432/tcp

5432/tcp with no arrow means the port is only available inside Docker's network. 127.0.0.1: limits a published port to the server itself. 0.0.0.0:5432->5432/tcp means Postgres is open on every interface.

Finally, test the panel from outside. Port 3000 is Dokploy's default, and Coolify uses 8000:

curl -m 5 http://your-server-ip:3000

It should time out. Both projects published critical advisories this year, including a pre-auth admin takeover in Dokploy, so treat this check as mandatory and keep the platform updated.

How Do You Verify HTTPS?

Check the redirect and the final response separately:

curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://app.example.com
curl -sS -o /dev/null -w '%{http_code}\n' https://app.example.com

Against this website, those print:

301 https://milanjovanovic.tech/
200

Then read the certificate the server actually presents:

echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
  | openssl x509 -noout -issuer -enddate
issuer=C=US, O=Google Trust Services, CN=WE1
notAfter=Jan 30 19:23:50 2027 GMT

Traefik serves a self-signed default certificate when it can't get a real one, and the issuer line catches that. Use the real hostname for all of this, because a response from the server's IP doesn't test domain routing.

If a request fails here, the status code tells you which layer to look at, and this table of Traefik's 404, 502, and 504 errors shows what each one means.

Now do something only a working application can do. Save a record through the public domain and confirm it's still there after a refresh.

How Do You Identify the Running Release?

Record the digest of the image your CI pushed. This only works on an image that came from a registry, and it errors on one you built locally:

docker image inspect --format '{{index .RepoDigests 0}}' postgres:18
postgres@sha256:edb0c08dc33befd0262ab3b5cf1c4f3f8cfd8b09e88c05b45adee29d6761ddb6

A mutable tag like latest can point at different images over time, so tag your own builds with the commit SHA. Have the application report that commit, and compare it after every deployment. The job can succeed while the old version still runs.

Rollback needs its own rehearsal. Redeploy the previous image, check its version, and run a read and a write. Replacing an image leaves database changes in place, so a migration the old version can't handle needs a separate plan, usually a forward fix.

Why Isn't Docker's Health Status Enough?

Docker's healthcheck reports the result of whatever command you configured. Here is what the sample did when I stopped its database:

database running:  /health/live 200   /health/ready 200
database stopped:  /health/live 200   /health/ready 503
docker inspect:    healthy restarts=0

The container healthcheck calls the liveness endpoint, so Docker kept reporting healthy with zero restarts while the application couldn't serve a database-backed request. That's correct behavior for a liveness check, since restarting the API wouldn't bring the database back. It also means your uptime monitor has to watch readiness.

A healthcheck doesn't restart anything on a plain Docker host either. Restart policies act when the process exits, so check the policy:

docker inspect --format '{{.Name}} health={{.State.Health.Status}} restarts={{.RestartCount}} policy={{.HostConfig.RestartPolicy.Name}}' app-api-1
/app-api-1 health=healthy restarts=0 policy=unless-stopped

The obvious commands don't trigger the policy, so kill the process from the host:

sudo kill -9 "$(docker inspect -f '{{.State.Pid}}' app-api-1)"

Here is what three different kills did to a container with --restart unless-stopped:

docker exec app-api-1 kill -9 1    running  restarts=0    (signal ignored)
kill -9 <host pid>                 running  restarts=1    (restarted)
docker kill app-api-1              exited   restarts=1    (stayed down)

Will Container Logs Fill Your Disk?

A small server can run out of disk because of its own logs. Docker's default json-file driver doesn't rotate logs unless you ask it to:

docker inspect --format '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}' app-api-1
json-file map[]

map[] means no limit. Set one per service in Compose:

services:
  api:
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'

The same command then prints json-file map[max-file:3 max-size:10m]. Recreate the container after changing this, because logging settings don't apply to one that's already running.

How Do You Know Your Data Is Safe?

List what each container actually stores:

docker inspect --format '{{.Name}}{{range .Mounts}} {{.Type}}:{{.Name}}->{{.Destination}}{{end}}' app-api-1 app-db-1
/app-api-1
/app-db-1 volume:app_pgdata->/var/lib/postgresql

Everything without a mount disappears when the container is replaced. Everything with one still depends on the host's storage, so it needs a copy somewhere else. Add anything Docker doesn't show, including deployment configuration.

Then look at the newest backup outside the server, and check its time and size:

rclone lsl r2:backups/prod | sort -k2,3 | tail -3

A file existing isn't enough, and neither is reading its table of contents. I cut a Postgres archive in half and ran two checks on it:

pg_restore --list          exit 0
pg_restore -f /dev/null    exit 1

--list only reads the table of contents at the start of the file, so it passes. Reading the whole archive with -f /dev/null catches the damage, and it makes a cheap nightly check. For the real test, restore into a scratch database and run the queries your application depends on.

What Do You Check After Launch?

Run your monitor outside the VPS, point it at the readiness endpoint, and cause a real failure. You need to receive both the DOWN and the UP alert before you can rely on it.

Confirm that security updates install on their own:

cat /etc/apt/apt.conf.d/20auto-upgrades
sudo unattended-upgrade --dry-run
test -f /var/run/reboot-required && echo 'reboot required'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

A dry run won't show you two gaps: it never reboots by default, and it skips Docker's repository. Unattended upgrades on Ubuntu covers both.

Keep an eye on disk usage, which is where a single server usually runs out first:

df -h /
docker system df

Avoid unattended docker system prune --volumes, because a volume that looks unused can be your only copy of something important. Put a monthly review in your calendar for updates, disk, backup freshness, and a test alert.

Summary

Treat each item as unfinished until you have seen the expected result yourself, and keep the output next to your deployment notes. Rerun the relevant checks after every significant change, especially the outside port scan and a restore.

If you are still planning the setup, how to learn self-hosting gives you the order to build these skills in. For guided practice with a supplied app and runbook, see my self-hosting course.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

What should a VPS deployment checklist include?

It should prove that users reach the app over HTTPS, administrators reach the server privately, you can name the running release, the app reports readiness accurately, logs cannot fill the disk, a stored backup restores, and an alert reaches a person.

Does UFW protect Docker containers on a VPS?

Not on its own. Docker publishes container ports using firewall rules that traffic reaches before UFW rules apply, so a published port can stay open even when UFW appears to block it. Use your provider firewall, publish fewer ports, and scan from outside the server to confirm.

How do I check that a backup works?

Restore a stored backup into a separate scratch database and run the queries your application needs. Listing the archive contents is not enough, because pg_restore --list still exits successfully on a truncated custom-format dump.

Why does Docker say my container is healthy when the app is failing?

A Docker healthcheck only reports the result of the command you configured. If it calls a liveness endpoint, the container stays healthy while a dependency such as the database is down. Monitor a readiness endpoint from outside the server as well.

Should I expose the Dokploy or Coolify panel to the internet?

No. A deployment panel controls your secrets and can run commands on the server, and both projects published critical security advisories in 2026. Keep it behind a private network or SSH tunnel, and keep the platform updated.

Browse all Self-Hosting guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.