Back to all posts

VPS Security Checklist 2026: 15 Pre-Deploy Checks

David Viejo
David Viejo ·

To secure a VPS before you deploy, allow SSH keys only with root and password login turned off, keep SSH off the public internet, and run a default-deny firewall that Docker-published ports can't slip past. Turn on automatic security updates, keep tested backups off the server, then scan it from outside to confirm only ports 80 and 443 answer.

A fresh VPS doesn't need a zero-day to get compromised. A password login, a database port that Docker published straight past your firewall, or a kernel nobody patched is enough. This checklist covers the 15 things to check on a new server before you put an app on it. Every item says why it matters, gives the commands for Ubuntu 24.04 LTS and Debian 12 (bookworm), and ends with a way to prove it worked. Where the two releases differ, and they do for SSH, fail2ban and logging, the difference is spelled out.

TL;DR: Log in with SSH keys only, with root and password logins turned off, and ideally keep SSH off the public internet. Run a default-deny firewall, and remember that Docker-published ports skip ufw, so bind them to 127.0.0.1. Turn on automatic security updates. Keep secrets out of images, back up off the server and test the restore. Then scan the server from outside: only 80 and 443 should answer.

Our VPS security scanner at /tools/vps-security-check is temporarily unavailable. It checked what is visible from the outside: open ports, TLS, security headers and exposed databases. This checklist covers all of that, plus the server-side settings a remote scan can't see.

The examples use deploy as the admin user and 203.0.113.10 as the server's public IP. Replace both. Keep a second SSH session open while you change SSH or firewall settings, so a mistake doesn't lock you out.

The 15 Checks at a Glance

#CheckDone when
1Sudo user with an SSH keyYou can sudo as a non-root user over key auth
2No root or password loginsshd -T shows permitrootlogin no and passwordauthentication no
3SSH off the public internetPort 22 is filtered on the public IP
4Default-deny firewallufw status verbose shows deny (incoming)
5Docker can't bypass the firewallNo docker-proxy listener on 0.0.0.0 or [::]
6Automatic security updatesunattended-upgrade --dry-run runs cleanly
7Brute-force bansfail2ban-client status sshd lists the jail
8Only expected ports listenss -tulpn and an outside nmap agree
9Docker socket and API not exposedNothing on 2375/2376, no container mounts docker.sock
10Containers run as non-rootConfig.User is set on every container
11TLS with automatic renewalA renewal dry run passes, HTTP redirects to HTTPS
12Secrets out of images and reposdocker history and gitleaks find nothing
13Off-server backups, testedA restore into a scratch container worked
14Time sync and kept logsClock synchronized, journal keeps past boots, Docker logs rotate
15Monitoring and alertsAn external check alerts you when the server is down

1. Create a Sudo User and Log In With an SSH Key

Why it matters: bots try root with common passwords against every public IP. A named user with a key gives them nothing to guess, and sudo leaves a record of who ran what.

On your laptop, create a key if you don't have one:

ssh-keygen -t ed25519 -C "you@laptop"

On the server, as root, create the user and add it to the sudo group (the same commands work on Ubuntu 24.04 and Debian 12):

adduser deploy
usermod -aG sudo deploy

On Debian 12, if you set a root password during installation, sudo may not be installed. Run apt install sudo first.

Back on your laptop, copy the public key across:

ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10

Verify: ssh deploy@203.0.113.10 logs you in without a password prompt, and sudo whoami prints root.

2. Turn Off Root and Password Logins in sshd

Why it matters: key-only login is the single change that stops password guessing outright. It only works if no other config file turns passwords back on.

Both releases start /etc/ssh/sshd_config with Include /etc/ssh/sshd_config.d/*.conf, and sshd uses the first value it reads for each keyword. Some provider images use cloud-init to place a file such as 50-cloud-init.conf in that directory with its own PasswordAuthentication line. Give your file a name that sorts first:

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
EOF

sudo sshd -t && sudo systemctl restart ssh

sshd -t checks the configuration before the restart, so a typo doesn't stop the server from accepting connections. The service is called ssh on both Ubuntu and Debian, not sshd. KbdInteractiveAuthentication replaces the old ChallengeResponseAuthentication keyword, which is now a deprecated alias.

Verify the configuration sshd actually uses, then try a password login from your laptop:

sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'
# permitrootlogin no
# passwordauthentication no
# kbdinteractiveauthentication no

ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive deploy@203.0.113.10
# deploy@203.0.113.10: Permission denied (publickey).

Changing the SSH port works differently on Ubuntu 24.04

A non-standard port cuts log noise but doesn't stop a targeted scan, so treat it as optional. If you do change it, the steps differ:

  • Ubuntu 24.04 starts sshd through systemd socket activation (ssh.socket), which Ubuntu has used by default since 22.10. On 24.04, a systemd generator reads Port and ListenAddress from sshd_config and its sshd_config.d drop-ins, but it only runs on a daemon reload. Restarting ssh alone leaves the socket listening on 22.
  • Debian 12 runs ssh.service directly, so a restart is enough.

Open the new port in the firewall first (check 4), add Port 2222 to 00-hardening.conf, then:

# Ubuntu 24.04
sudo sshd -t && sudo systemctl daemon-reload && sudo systemctl restart ssh.socket

# Debian 12
sudo sshd -t && sudo systemctl restart ssh

Verify: sudo ss -tlnp | grep 2222 shows a listener, and a new session with ssh -p 2222 works before you remove the old firewall rule.

3. Take SSH Off the Public Internet

Why it matters: key-only SSH is safe against password guessing, but a public port 22 is still exposed to every future sshd vulnerability and fills your logs. If port 22 isn't reachable, none of that matters.

The cleanest way is a private network. With Tailscale, the server joins your tailnet, the firewall allows everything on the tailscale0 interface, and the public SSH rule goes away:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo ufw allow in on tailscale0
# Test ssh deploy@<tailscale-ip> from a new terminal, then:
sudo ufw delete limit 22/tcp

The Tailscale VPS guide walks through each step, including Tailscale SSH and ACLs. If you'd rather not run a VPN, your provider's cloud firewall can allow port 22 only from your own IP. Because a cloud firewall filters traffic before it reaches the VM, nothing on the server can open a hole in it.

Verify from a machine outside your tailnet: nmap -Pn -p 22 203.0.113.10 reports filtered, and ssh deploy@<tailscale-ip> still works.

4. Run a Default-Deny Firewall

Why it matters: every service you install later (a database, an admin panel, a debug server) becomes public the moment it listens on 0.0.0.0. A default-deny policy means a new port is closed until you open it.

ufw ships with Ubuntu 24.04 but starts disabled. On Debian 12, install it first with sudo apt install ufw. Then, on both:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp      # skip if SSH only runs over Tailscale
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

ufw limit denies an address that opens six or more connections within 30 seconds, a cheap extra layer for SSH. IPv6 rules are on by default (IPV6=yes in /etc/default/ufw), so these rules cover both protocols.

Using nftables directly on Debian 12

Pick one tool: ufw or hand-written nftables rules, not both. nftables has been Debian's default firewall framework since Debian 10, and ufw runs on top of it via the iptables-nft layer. If you write nftables rules by hand instead, watch one thing: the stock /etc/nftables.conf starts with flush ruleset, which deletes every table, including the ones Docker creates when it starts. Manage only your own table:

sudo tee /etc/nftables.conf > /dev/null <<'EOF'
#!/usr/sbin/nft -f
table inet host_filter
delete table inet host_filter

table inet host_filter {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif "lo" accept
    meta l4proto { icmp, ipv6-icmp } accept
    tcp dport { 22, 80, 443 } accept
  }
}
EOF

sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables

The first two lines create and then delete the table, so reloading the file replaces your rules without touching Docker's. If you ever ran the stock file on a Docker host, run sudo systemctl restart docker so Docker recreates its rules. Keep ipv6-icmp allowed: IPv6 neighbor discovery breaks without it.

Verify: sudo ufw status verbose shows Default: deny (incoming), allow (outgoing) and only your rules (or sudo nft list table inet host_filter shows policy drop). The real test is the outside scan in check 8.

5. Stop Docker From Publishing Ports Past the Firewall

Why it matters: this is the check most self-hosters miss. When you run docker run -p 5432:5432 postgres, Docker writes its own iptables rules, and Docker's documentation says that traffic to a published container "gets diverted before it goes through the ufw firewall settings". Your ufw status says deny, and the database is still on the internet. The same applies to a hand-written nftables input chain: published-port traffic is forwarded to the container, so it never reaches input.

Docker also publishes to every host address by default (0.0.0.0 and [::]), and its port-publishing docs call that insecure by default. Fix it in this order.

Don't publish what doesn't need publishing. Containers on the same Docker network reach each other by name. A database that only your app talks to needs no ports: entry at all.

Bind what you do publish to localhost, so only the host (and the reverse proxy on it) can reach it:

docker run -d -p 127.0.0.1:8080:80 nginx
# docker-compose.yml
services:
  db:
    image: postgres:16
    ports:
      - "127.0.0.1:5432:5432"

Make localhost the default for anything someone publishes without a host IP. The ip key covers the default bridge; default-network-opts covers user-defined networks, which is what Compose creates. Merge this into /etc/docker/daemon.json:

{
  "ip": "127.0.0.1",
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  }
}

Then sudo systemctl restart docker. The new default applies to containers and networks you create afterwards, so recreate existing ones (docker compose up -d --force-recreate). Docker Engine releases before 28.0 also let hosts on the same layer-2 segment reach ports published to localhost, so keep Docker current (check 6).

If a port must be public but restricted, filter it in the DOCKER-USER chain, which Docker processes before its own rules. Packets there have already been translated to the container's address, so match the original port with conntrack, as Docker's docs describe:

# Drop new inbound connections from eth0 to published host port 5432
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 5432 --ctdir ORIGINAL -j DROP

Replace eth0 with your public interface (ip -br addr). Rules added with iptables don't survive a reboot unless you persist them, which is one more reason to prefer localhost bindings. A cloud firewall in front of the server also sidesteps the problem entirely.

Verify:

sudo ss -tlnp | grep docker-proxy
# Every line should show 127.0.0.1:<port> or [::1]:<port>, never 0.0.0.0 or [::]

On a server where Temps deploys your apps, the database and other managed services, and app containers, are published on 127.0.0.1 only (see firewall rules). Containers you start yourself with docker run or your own Compose files don't get that protection unless you do the above.

6. Turn On Automatic Security Updates

Why it matters: a published security fix only protects servers that install it, and attackers scan for unpatched versions as soon as fixes are announced. Daily unattended security updates close that window without you having to remember to log in.

On Ubuntu 24.04, unattended-upgrades is installed and enabled by default. Check that both lines are "1":

cat /etc/apt/apt.conf.d/20auto-upgrades
# APT::Periodic::Update-Package-Lists "1";
# APT::Periodic::Unattended-Upgrade "1";

On Debian 12, install and enable it:

sudo apt install unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades   # answer Yes

Kernel and libc updates need a reboot to take effect. To let the server reboot itself at a quiet hour, set these in /etc/apt/apt.conf.d/50unattended-upgrades:

Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";

Two gaps remain. The default allowed origins are your distribution's security updates, so packages from third-party repositories, including Docker's own apt repository, need a scheduled sudo apt update && sudo apt upgrade. And OS updates don't patch your container images: rebuild and redeploy them so they pick up new base images.

Verify:

sudo unattended-upgrade --dry-run --debug
systemctl list-timers apt-daily-upgrade.timer
ls /var/log/unattended-upgrades/

The dry run should list the security origins it would use and finish without errors. On Ubuntu, /var/run/reboot-required exists when an update is waiting for a reboot.

7. Ban Brute-Force Attempts With fail2ban

Why it matters: with key-only SSH, guessing can't succeed, but fail2ban still cuts the noise and blocks scanners that hammer any service that logs failures. It matters most while SSH is still public.

This is where Ubuntu 24.04 and Debian 12 differ most:

  • Ubuntu 24.04 ships fail2ban 1.0.2-3ubuntu0.1, whose packaged defaults already enable the sshd jail, read the systemd journal (backend = systemd) and ban with nftables.
  • Debian 12 ships fail2ban 1.0.2-2. Debian 12 no longer installs rsyslog, so there is no /var/log/auth.log to read, and this version's defaults don't switch to the journal. On a fresh install the default sshd jail can fail, taking the fail2ban service with it (Debian bug #1037437, fixed in later Debian releases).

The same jail.local works on both and makes Debian 12 behave like Ubuntu:

# Debian 12: python3-systemd is only a Recommends there, so install it explicitly
sudo apt install fail2ban python3-systemd nftables
# Ubuntu 24.04: sudo apt install fail2ban nftables

sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
backend = systemd
banaction = nftables
banaction_allports = nftables[type=allports]
bantime = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
EOF

sudo systemctl enable fail2ban && sudo systemctl restart fail2ban

Installing the nftables package on Ubuntu makes sure the nft command the ban action calls is present. fail2ban's nftables actions create their own table, so they coexist with ufw.

Verify the jail is running, then test a ban with an address you don't use:

sudo fail2ban-client status sshd
sudo fail2ban-client set sshd banip 198.51.100.77
sudo nft list ruleset | grep 198.51.100.77
sudo fail2ban-client set sshd unbanip 198.51.100.77

8. Audit Listening Ports and Remove Unused Services

Why it matters: the firewall is your second line. The first is not running the service at all. This check also catches anything that slipped past checks 4 and 5.

From the server, list everything listening on TCP and UDP with the owning process:

sudo ss -tulpn

Read the local address column. 127.0.0.1 and [::1] are local-only. 0.0.0.0, [::] and * mean every interface. A line owned by docker-proxy is a published container port (check 5). On Ubuntu 24.04, the SSH listener may show systemd as its owner because of socket activation.

For anything you don't recognize, find the unit and remove or disable it:

systemctl list-units --type=service --state=running
sudo systemctl disable --now <service>
sudo apt purge <package>        # if you never need it

Then scan from outside, from a machine that is not on the server's private network, and only against servers you own:

nmap -Pn -p- --open 203.0.113.10          # all TCP ports, IPv4
nmap -6 -Pn -p- --open 2001:db8::10       # repeat for the server's IPv6 address
sudo nmap -Pn -sU --top-ports 50 203.0.113.10

Verify: the outside scan shows only what you meant to expose, normally 80 and 443 (plus 22 if SSH is still public). If ss shows a service on 0.0.0.0 that nmap can't see, the firewall is doing its job; if nmap sees it too, go back to checks 4 and 5.

9. Never Expose the Docker Socket or the Docker API

Why it matters: control of the Docker daemon is control of the host. Docker's own post-install docs warn that "the docker group grants root-level privileges to the user", and anything that can reach /var/run/docker.sock or a TCP API endpoint can start a privileged container that mounts /.

Check that the daemon isn't listening on TCP (2375 is the conventional plaintext port, 2376 TLS):

sudo ss -tlnp | grep -E ':(2375|2376)\b'
ps -o args= -C dockerd                       # should not contain -H tcp://
grep -s hosts /etc/docker/daemon.json

Check which users are in the docker group, and which containers mount the socket:

getent group docker
docker ps -q | xargs -r docker inspect --format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock

To manage Docker remotely, don't open the API. Use SSH, which Docker supports natively: docker context create remote --docker host=ssh://deploy@203.0.113.10, or DOCKER_HOST=ssh://deploy@203.0.113.10 for one command. If a tool such as a reverse proxy or update watcher insists on the socket, put a proxy in front of it that allows only the API calls it needs, and never give it to a container that serves public traffic.

Verify: no TCP listener, only admins in getent group docker, and the mount check prints nothing you didn't put there deliberately.

10. Run Containers as Non-Root With Least Privilege

Why it matters: an app bug that gives an attacker code execution inside a root container is one kernel bug away from the host. A non-root user without capabilities makes that much harder.

Set a user in the image:

# Debian or Ubuntu base image; Alpine uses adduser -S instead
RUN useradd --system --uid 10001 app
USER 10001

When you run containers yourself, drop what the app doesn't need:

services:
  app:
    image: ghcr.io/you/app:latest
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

Docker already drops most Linux capabilities by default; its security docs recommend removing every capability your process doesn't explicitly need. Docker also has a rootless mode that runs the daemon itself as a normal user, worth considering on a server you share.

Verify every running container:

docker ps -q | xargs -r docker inspect --format '{{.Name}} user={{.Config.User}} privileged={{.HostConfig.Privileged}} cap_add={{.HostConfig.CapAdd}}'

An empty user= means the container runs as root. privileged=true should never appear on a public-facing service.

11. Use TLS Everywhere With Automatic Renewal

Why it matters: an expired certificate is an outage, and an HTTP-only admin page sends its session cookie in clear text. Renewal also needs automating more than ever: Let's Encrypt is shortening certificate lifetimes, to 64 days for its default profile from February 10, 2027 and 45 days from February 16, 2028. It warns that renewing on a hard-coded 60-day interval will no longer be enough.

If you terminate TLS with Nginx and Certbot, install it from the distribution packages. Certbot's docs say most installations come with automatic renewal preconfigured as a scheduled task; the verify step below confirms yours has one:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com

Certbot's own site also offers a snap install. Caddy, Traefik and most deployment platforms renew automatically. Whatever you use, also redirect HTTP to HTTPS and send at least Strict-Transport-Security and X-Content-Type-Options: nosniff.

"Everywhere" includes traffic between your own servers. A database connection from an app server to a database server should run over a private network that encrypts it (WireGuard or Tailscale) or over TLS, never in plain text across the public internet.

Verify:

sudo certbot renew --dry-run
systemctl list-timers | grep -i certbot
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate
curl -sI http://example.com | head -n 1           # expect a 301 or 308
curl -sI https://example.com | grep -i -E 'strict-transport|x-content-type'

12. Keep Secrets Out of Images and Repositories

Why it matters: a secret baked into an image layer ships with every copy of the image, and one committed to Git stays in the history after you delete the file.

  • Add .env, keys and credentials to .dockerignore and .gitignore.
  • Don't pass secrets as ARG or ENV during a build. Docker's docs say build arguments and environment variables "persist in the final image". Use a build secret mount instead:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .
  • Inject runtime secrets as environment variables or files from your deployment tool, and keep any .env on the server readable only by its owner: chmod 600 .env.
  • If a secret ever reached Git or a pushed image, rotate it. Rewriting history doesn't un-leak a key someone already copied.

Verify:

docker history --no-trunc ghcr.io/you/app:latest | grep -iE 'secret|token|password|key'
docker image inspect --format '{{json .Config.Env}}' ghcr.io/you/app:latest
gitleaks git -v .        # scans the full Git history of the current repo

13. Back Up Off the Server and Test the Restore

Why it matters: a backup on the same server disappears with the server, and a backup an attacker on the server can delete isn't a backup either. A backup you have never restored is a hope, not a plan.

Three rules: store backups with a different provider or account than the server, keep several versions (object storage with versioning or object lock), and restore one on a schedule.

A minimal PostgreSQL dump and an off-server copy:

docker exec app-db pg_dump -U app -Fc app > app-$(date +%F).dump
aws s3 cp app-$(date +%F).dump s3://your-backup-bucket/postgres/

The restore test, into a throwaway container running the same major version as production:

docker run -d --name restore-test -e POSTGRES_PASSWORD=test postgres:16
until docker exec restore-test pg_isready -h 127.0.0.1 -U postgres; do sleep 1; done
docker exec -i restore-test pg_restore -U postgres --create -d postgres < app-$(date +%F).dump
docker exec restore-test psql -U postgres -d app -c 'SELECT count(*) FROM users;'
docker rm -f restore-test

Provider snapshots are useful for a fast rollback of the whole disk, but they live with the same provider. For a scheduled setup with retention, see how to back up PostgreSQL in Docker automatically.

Verify: the restored row counts match production, and you know how long the restore took.

14. Sync the Clock, Keep Logs and Rotate Them

Why it matters: TLS, TOTP codes and log correlation all depend on correct time. Logs that vanish on reboot, or fill the disk, fail you exactly when you need them.

Ubuntu 24.04 and Debian 12 both use systemd-timesyncd by default (Ubuntu moved to chrony in 25.10). Check it:

timedatectl status
# System clock synchronized: yes
# NTP service: active

If NTP service shows n/a, install it with sudo apt install systemd-timesyncd.

For logs, journald stores them on disk when /var/log/journal exists and only in memory otherwise. Debian 12 no longer installs rsyslog, so SSH logins live in the journal: use journalctl -u ssh rather than /var/log/auth.log. Check that past boots are kept:

journalctl --list-boots
# If only the current boot is listed:
sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald

Docker's default json-file log driver does no rotation: max-size defaults to unlimited. Add rotation to the same /etc/docker/daemon.json as check 5:

{
  "ip": "127.0.0.1",
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  },
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Restart Docker. Existing containers keep their old logging settings until they are recreated.

Verify: timedatectl reports a synchronized clock, journalctl --list-boots shows more than one boot after your next reboot, and docker inspect --format '{{.HostConfig.LogConfig}}' <container> shows the max-size on recreated containers.

15. Set Up Monitoring and Alerts

Why it matters: every check above can drift. A disk fills, a certificate fails to renew, or someone publishes a port. You want to hear about it from an alert, not a user.

The minimum set:

  • An external uptime check on each public URL. A monitor that runs on the same server can't tell you that the server is down, so at least one check has to run somewhere else.
  • Certificate expiry alerts a couple of weeks ahead. Let's Encrypt recommends monitoring alongside automated renewal so you hear when a renewal didn't happen.
  • Disk usage alerts at around 80%. df -h / and docker system df show where space went.
  • A periodic look at who logged in: journalctl -u ssh --since "-7 days" | grep Accepted.

Verify: stop a test service or point a monitor at a URL that returns 500, and confirm the alert reaches you.


Final Verification: Run These Before You Deploy

From your laptop, outside the server's private network:

nmap -Pn -p- --open 203.0.113.10        # expect 80 and 443 only
nmap -6 -Pn -p- --open 2001:db8::10     # same for IPv6
ssh -o PubkeyAuthentication=no deploy@203.0.113.10   # expect "Permission denied (publickey)" or a timeout
curl -sI http://example.com | head -n 1  # expect a redirect to HTTPS

On the server:

sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication)'   # both "no"
sudo ufw status verbose                     # deny (incoming)
sudo ss -tulpn                              # nothing unexpected on 0.0.0.0, [::] or *
sudo ss -tlnp | grep docker-proxy           # only 127.0.0.1 / [::1]
sudo unattended-upgrade --dry-run --debug   # runs cleanly
sudo fail2ban-client status sshd            # jail active
timedatectl | grep synchronized             # yes
sudo certbot renew --dry-run                # if you use Certbot

If all of that passes and you have restored a backup at least once, the server is ready for an app.

If You Deploy With Temps

Temps is a self-hosted deployment platform that runs on your VPS, so most of this list stays your job: Temps does not configure a host firewall, install OS updates or manage SSH. It does take care of several app-level items:

  • TLS: custom domains get Let's Encrypt certificates that are issued and renewed automatically (HTTP-01, or DNS-01 through Cloudflare, Route 53 and other DNS providers).
  • Port exposure: app containers and managed databases are published on 127.0.0.1 only, so check 5 is handled for what Temps runs. The installer binds the console API on port 8081 on all interfaces; Temps runs on the host rather than in a container, so ufw does filter it. Keep 8081 closed, and use the admin listener to move the console onto a private address.
  • Container privileges: app containers start with all Linux capabilities dropped, no-new-privileges and a 512-process limit. Compose files that mount the Docker socket or request privileged or extra capabilities are rejected.
  • Secrets: environment variable values are encrypted at rest with AES-256-GCM, and --secret makes a variable write-only. Back up ~/.temps/encryption_key separately from the database.
  • Abuse controls: an instance-wide IP block list enforced at the proxy, security headers that are off by default (enable a preset such as Moderate per project), and Attack Mode, a manual per-project proof-of-work challenge for bot traffic and L7 floods. It is not volumetric DDoS protection.
  • Backups and monitoring: scheduled backups to S3 for managed databases and the control-plane database (not app volumes), uptime monitors that alert on the first failed check, and experimental Trivy scanning of deployed images for known CVEs.

The production checklist covers the Temps-specific go-live steps.

Frequently Asked Questions

How do I check if my VPS is secure?

Check it from both sides. From outside, run nmap -Pn -p- --open <ip> against IPv4 and IPv6: only the ports you meant to expose, normally 80 and 443, should answer. On the server, sudo sshd -T should show root and password logins disabled, sudo ss -tulpn should list nothing unexpected on 0.0.0.0, and sudo unattended-upgrade --dry-run should run cleanly. The final verification section above has the full list.

Is ufw enough to protect a server that runs Docker?

No. Docker writes its own iptables rules for published ports, and that traffic is diverted before ufw's rules see it. Bind published ports to 127.0.0.1, set "ip": "127.0.0.1" and the default-network-opts bind address in /etc/docker/daemon.json, or filter in the DOCKER-USER chain. A cloud firewall in front of the server also works because it filters before traffic reaches the VM.

Should I change the default SSH port?

It's optional. A different port reduces log noise from bots but doesn't stop anyone who scans every port. Key-only login and keeping SSH off the public internet matter far more. On Ubuntu 24.04, a port change needs systemctl daemon-reload and systemctl restart ssh.socket, because SSH is socket-activated; on Debian 12, restarting ssh is enough.

Do I still need fail2ban if I only allow SSH keys?

It's not strictly required, since key-only login can't be brute-forced, but it's cheap and it cuts the noise while SSH is public. On Debian 12, set backend = systemd and install python3-systemd, or the sshd jail won't start because there's no /var/log/auth.log.

Does unattended-upgrades reboot the server?

Not by default. It installs security updates daily, but kernel updates only take effect after a reboot. Set Unattended-Upgrade::Automatic-Reboot "true" and a time in /etc/apt/apt.conf.d/50unattended-upgrades, or reboot on a schedule yourself.

What happened to the Temps VPS security scanner?

The free scanner at /tools/vps-security-check is temporarily unavailable. It tested what's visible from outside: open ports, TLS, security headers and exposed databases. Checks 5, 8 and 11 in this list cover the same ground with nmap, openssl and curl, and the rest cover the server-side settings no outside scan can see.

Does Temps secure the server for me?

Partly. Temps handles automatic Let's Encrypt certificates, keeps app and database ports on 127.0.0.1, drops container capabilities and encrypts environment variables at rest. It does not configure the host firewall, SSH or OS updates, and it can't vouch for containers you run outside it, so most of this checklist still applies. See the security overview for what Temps covers.

#vps security checklist#vps security checklist 2026#how to secure a vps#is my vps secure#harden ubuntu server#ubuntu 24.04 ssh hardening#debian 12 server hardening#docker ufw bypass#docker-user chain#unattended-upgrades#fail2ban debian 12#server firewall#self-hosting security