How to secure a VPS before you deploy
So open a second SSH session before you run ufw enable or disable password SSH. The most dangerous hardening command is the one you run before proving that another way back in works.
A secure VPS comes from a sequence: preserve recovery, test key-based administration, reduce public exposure, remove unnecessary privilege, patch the system, add detection, and verify restoration. This guide is for Ubuntu 24.04 and similar Debian-based systems. Provider panels, Docker networking, and managed images may change the details, so treat the commands as a controlled procedure rather than a universal script.
In this article
- The first ten minutes are when hardening most often locks you out
- Make recovery a gate before changing SSH
- Replace password and root SSH with a tested key
- A green firewall status proves very little
- Remove services and privileges you cannot justify
- Patch the base system and decide who owns updates
- CIS Level 1 is a baseline, not a verdict
- Choose detection you can operate
- Keep private traffic private
- Verify the result and assign ownership
The first ten minutes are when hardening most often locks you out
Start by asking whether you can still administer, update, monitor, and restore the server after the next change.
And keep the original SSH session open while changing authentication or firewall rules. Have provider-console access ready. Record what is listening before blocking or removing anything. A firewall status or CIS score proves one operation completed. It doesn’t prove your applications, IPv6 path, containers, alerts, or backups work.
A safe baseline follows, but no guide can certify your provider routing, application binds, or container rules. Test those from outside the VPS.
So follow this sequence: recovery, SSH, network exposure, services and privilege, updates, CIS auditing, detection, private traffic, and acceptance testing.
Make recovery a gate before changing SSH
Before touching authentication or firewall rules, collect:
- Provider recovery access. Test the console or recovery feature your provider actually supplies. Providers differ, and a feature shown in a dashboard isn’t useful until you know how to use it.
- Current access details. Record the username, address, SSH port, and credentials currently in use.
- A backup or snapshot. Know what it captures, how restoration works, and what happens to data written afterward.
- A second terminal. Keep the first session open until the replacement login succeeds.
- A service inventory. Record current listeners and their purpose.
A snapshot is not the same thing as a tested restore. For production data, rehearse a representative restoration in a non-production copy or follow the provider’s documented recovery process before deployment.
And the DigitalOcean server-security guide recommends keeping an existing session open while testing new SSH authentication. That small habit prevents a surprisingly expensive mistake.
Inspect listeners with:
sudo ss -tulpn
Record each TCP or UDP listener, its address, port, owning process, and reason for being public. Suppose this fresh Ubuntu 24.04 VPS hosts a small web application: SSH administration is required, HTTPS runs on port 443, HTTP may redirect or support certificate issuance, and the database has no reason to be publicly reachable.
Keep that inventory. You will use it as an acceptance test later.
Replace password and root SSH with a tested key
Create a named, non-root administrative account with sudo. If your provider gave you only root access, create the account through its documented Ubuntu workflow or provider console, then install the key for it. The account-creation command varies with your environment, so this guide won’t pretend there is one safe copy-paste branch.
Generate an Ed25519 key on your local computer:
ssh-keygen -t ed25519 -C "you@example.com"
Protect the private key with a passphrase and keep it on your computer. A properly protected Ed25519 private key makes online password guessing irrelevant for this login path. It cannot protect a compromised endpoint.
Copy the public key to the intended account:
ssh-copy-id user@your-server
If ssh-copy-id isn’t available, add the public key through the provider console or key-injection workflow. The key belongs in that account’s ~/.ssh/authorized_keys, not in root’s directory by accident.
Check the key directory and file:
ls -ld ~/.ssh
ls -l ~/.ssh/authorized_keys
They must belong to the intended user. Correct ownership according to the account’s home-directory layout before testing; for a normal user home, that generally means the user owns both the directory and file. Apply restrictive permissions:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Open a second terminal and test:
ssh user@your-server
Leave the first session open. Audit deployment jobs, monitoring agents, and recovery scripts before disabling passwords. Migrate anything that still depends on password authentication.
Edit the daemon configuration:
sudoedit /etc/ssh/sshd_config
Set:
PasswordAuthentication no
PermitRootLogin no
Before restarting, validate the configuration. On Ubuntu, sshd -t usually resolves directly; if it doesn’t, use the daemon binary path supplied by your installed OpenSSH package. The command must return no output and a successful exit status:
sudo sshd -t
Keep the existing session open while validating and restarting:
sudo systemctl restart ssh
Test the second terminal again. PermitRootLogin no blocks root through SSH, while provider-console recovery remains separate.
Fail2ban may slow abuse, but public SSH still needs key authentication, a non-root account, least privilege, and a recovery path you have tested.
A green firewall status proves very little
A firewall should follow the service inventory. For the example host, allow SSH and the web traffic it needs, then leave the database unpublished.
At the provider layer, restrict SSH to a known administrator address, VPN, or provider-controlled path where possible. On Ubuntu, allow SSH before enabling UFW:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
If SSH uses port 2222, allow that port instead before enabling UFW:
sudo ufw allow 2222/tcp
A general OpenSSH rule may expose SSH globally. If you know the administrator’s source address, narrow it:
sudo ufw allow from 203.0.113.25 to any port 22 proto tcp
Replace the example address with a real trusted source. Keep console access available before changing it.
The shown allow rules rely on UFW’s default policy. Confirm the verbose output says:
Default: deny (incoming)
A firewall that says “active” is not proof that your applications are protected. Docker can install iptables rules for published ports, and IPv6 can remain exposed through an address you never tested.
Check UFW’s IPv6 setting in /etc/default/ufw, then test the actual public IPv4 and IPv6 addresses from an external network. ufw status verbose shows policy, not whether your application is reachable through every route.
Docker’s behavior needs a separate decision (see our guide to securing Docker containers in production). If the reverse proxy runs on the host and the application needs no external path, publish the container on loopback:
docker run -p 127.0.0.1:8080:8080 example/app
That is a host-publishing choice, not a complete Docker network policy. Verify the port externally. For a database, use an internal Docker network or localhost and publish no host port.
Remove services and privileges you cannot justify
Use the listener inventory to investigate, rather than running a removal script. Identify the process behind each public socket and understand its dependencies before disabling a unit or removing a package.
Bind databases, admin panels, development servers, and internal APIs to localhost or private addresses where possible. Review reverse-proxy routes and application bind addresses too. A firewall can’t fix an endpoint the application deliberately exposes.
Run the application under a dedicated non-root user. Give it ownership only of the directories it needs. Restrict secret files and directories with suitable ownership and permissions, and keep credentials out of repositories, world-readable configuration paths, and shell history.
Review human accounts. Remove abandoned access, use named accounts for attribution, and grant sudo only for work that requires it.
The stopping rule is simple: if you cannot explain why a listener, account, or privileged process exists, investigate it before deployment. Investigate it before removing it. Keep it private unless you have a reason to expose it.
Patch the base system and decide who owns updates
Update the base system:
sudo apt update
sudo apt upgrade
Review package changes before accepting them. Updates can change service behavior, dependencies, configuration formats, and reboot requirements. Define a maintenance window, rollback path, application smoke test, and owner.
On Ubuntu, check for a pending reboot with:
test -f /var/run/reboot-required && cat /var/run/reboot-required
Schedule the reboot when the service allows it. A kernel update is the obvious case, but important libraries and services may also need restarting.
Canonical’s Ubuntu Security Guide documentation describes Ubuntu Pro as providing more than 10 years of security fixes and rebootless kernel patching. Pro is an option for support or coverage requirements; it doesn’t secure your application or replace routine updates. Rebootless kernel patching also doesn’t apply to every update.
CIS Level 1 is a baseline, not a verdict
Formal benchmarks help when you need repeatable checks, audit evidence, or a shared hardening standard. They become harmful when you apply controls without understanding your application and recovery plan.
These commands are for Ubuntu 24.04 with Ubuntu Pro attached. They are not a generic Debian procedure. USG requires Ubuntu Pro:
sudo pro enable usg
sudo apt install usg
Audit before changing live configuration:
sudo usg audit cis_level1_server
Read every finding. Record exceptions for required services, logging, time synchronization, firewall choice, remote access, and recovery. Only then consider remediation:
sudo usg fix cis_level1_server
usg fix can alter live configuration. Run it only after reviewing the findings, preserving a rollback path, and keeping console access available.
For a tailored audit, use the generated file according to Ubuntu’s USG documentation:
sudo usg generate-tailoring cis_level1_server tailoringfile.xml
sudo usg audit --tailoring-file tailoringfile.xml
Level 1 is the practical baseline for most small production VPS deployments. Level 2 fits environments where security takes priority over convenience and may increase logging, storage use, or performance costs. Applying Level 2 blindly to a working production server is security theatre with operational costs. Measure the environment and make exceptions deliberately.
CIS alignment doesn’t prove that your application is safe, alerts arrive, or restores work.
Choose detection you can operate
Send important logs and alerts off-host. A warning that exists only on a compromised VPS is weak evidence, and a tool nobody reviews is expensive decoration.
AIDE or Tripwire can compare the current filesystem with a known-good database and report unexpected changes. I would create that baseline after the server reaches an approved state, then send notifications somewhere the VPS cannot erase.
Use psad when you need alerts about port scans recorded by the firewall. It can change firewall rules according to its configuration, so test those responses before relying on them.
Bro, now called Zeek, provides broader network event and policy monitoring. Skip it on a small single VPS unless you already have a collector and a person responsible for reviewing events. Otherwise you are collecting traffic-shaped paperwork.
| Tool | Choose it when | Stop if |
|---|---|---|
| AIDE or Tripwire | Filesystem changes need review | You cannot maintain a trusted baseline or off-host alerts |
| psad | Port scans need alerts or response | Nobody owns firewall-event review |
| Bro/Zeek | You already operate network event collection | You lack a collector, storage, or event-review owner |
Fail2ban slows repeated login abuse. Keys, least privilege, and recovery protect the account itself.
Keep private traffic private
DigitalOcean’s VPC best practices describe VPC networks as a way to isolate resources from the public internet. Private routing still doesn’t authorize traffic by itself. Provider controls, host firewalls, service binds, and application authentication remain necessary.
For multiple resources, design private subnets and gateways at provisioning time. Moving an existing server into a VPC can require IP and routing changes. Use a provider VPC first for a single-region workload; add a VPN when you need encrypted cross-region traffic or remote-user access.
A defensible layout looks like this:
- The VPS exposes only required web ports publicly.
- SSH uses a restricted administrator, VPN, or provider recovery path.
- The database listens on localhost or a private interface.
- Backups go to a separate destination with controlled access.
- Logs and alerts leave the VPS.
- Restoration has been rehearsed.
Verify the result and assign ownership
Run the acceptance test from the server and an external network:
- Open a new SSH session with the administrative key.
- Confirm the application works over HTTPS.
- Confirm HTTP redirects or serves only the endpoint you intended.
- Compare UFW and provider-firewall rules.
- Test unexpected ports over external IPv4 and, if enabled, external IPv6.
- Compare current listeners with the inventory from provisioning.
- Confirm the database is unreachable from the public internet.
- Confirm logs and detection alerts arrive off-host.
- Restore representative production data in a non-production copy. Can’t do that? Rehearse the provider’s recovery process with equivalent data and record the limitation.
- Run the USG audit again if you adopted a CIS baseline.
Write down the operating agreement:
Owner:
Public ports and protocols:
IPv4/IPv6 exposure checked on:
SSH recovery method:
Last backup-restore test:
Last external exposure test:
Next patch window:
Last hardening audit:
Update it after every major change and during maintenance appropriate to the service. If you cannot test restore, access, and external exposure, stop deployment and fix that gap.