Most developers obsess over writing secure code. Parameterised queries, input validation, dependency scans, the whole stack. Good habits.
Then they deploy that clean code onto a server with port 22 open to the world, root login enabled, the app running as root, and no rate limiting in front of anything. Clean code on a misconfigured server is still wide open. You did the hard part for nothing.
Here are 5 server hardening basics to lock down before your app goes live. None of them are exotic. All of them block the bulk of automated traffic that hits a fresh VPS within minutes of its IP being assigned.
Not a developer? Read this part.
The rest of this article gets technical fast. If you run a business but do not write code, you do not need to understand the commands. You just need to know what to ask your dev team before launch. Here are the 5 questions:
- "Did you turn off password login on the server and switch to SSH keys?" (yes / no)
- "Is there a firewall, and have you closed every port except the ones our app actually uses?" (yes / no)
- "Is Fail2Ban (or something like it) installed to block repeated failed login attempts?" (yes / no)
- "Does our app run as a regular user, not as root?" (yes / no)
- "Is there a reverse proxy like Nginx or Caddy in front of our app, handling HTTPS?" (yes / no)
If the answer to any of these is "no" or "not sure," do not launch yet. None of these take more than 30 minutes for someone who knows what they are doing. Whoever set up your server should be able to confirm all 5 in writing.
1. Disable root login and use SSH keys
The default state of a fresh Linux VPS is the worst possible state. Port 22 open, root login allowed, password authentication enabled. Within a few hours of provisioning, you will see thousands of failed login attempts from bots scanning for weak root passwords.
Two changes close that door.
First, create a regular user with sudo access and switch to SSH key authentication. Generate a key on your laptop with ssh-keygen -t ed25519, copy the public key to the server with ssh-copy-id user@yourserver, log in once to confirm it works, then disable password and root login in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Restart SSH with sudo systemctl restart sshd and keep your original session open until you have confirmed key login works from a second terminal. If you lock yourself out, you will be paying your VPS provider for a console session at 2am.
SSH keys are not "harder to crack". They are mathematically infeasible to brute-force in any reasonable timeframe. Passwords are guessable. There is no contest.
2. Set up a firewall
Every open port is a service. Every service has a version. Every version has known vulnerabilities published somewhere. A firewall lets you say "only these ports, nothing else" and stop arguing.
On Ubuntu, UFW gets you 90% of the way in three commands:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
That is it. Postgres on 5432, Redis on 6379, your app server on 3000 or 8080? None of them are reachable from the internet. Your web app talks to them on localhost, which is exactly what you want.
A firewall is just a corridor of locked doors. Open only the ones your app actually walks through.
The most common mistake we see in code audits: a .env with DB_HOST=0.0.0.0 and a Postgres instance listening on all interfaces "for development convenience." A firewall saves you from yourself when that config slips into production.
If your VPS provider has a network-level firewall (DigitalOcean Cloud Firewalls, AWS Security Groups, Hetzner Cloud Firewall), use both. Defence in depth is real, not theatre.
3. Install Fail2Ban
Fail2Ban watches your log files. When an IP fails SSH logins, web app logins, or matches any pattern you configure, Fail2Ban temporarily adds an iptables rule to drop traffic from that IP. By default the ban is 10 minutes after 3 failed attempts within a 10-minute window.
Install and enable it:
sudo apt install fail2ban
sudo systemctl enable --now fail2ban
The default config already protects SSH. Custom jails for Nginx auth, WordPress login, or your app's failed-login endpoint are a few lines of config away.
Two things to know. First, Fail2Ban does not replace a firewall. The firewall decides what is reachable. Fail2Ban decides who has burned their welcome. They are layered. Second, if your app sits behind Cloudflare or a load balancer, every request looks like it comes from the same proxy IP, which makes Fail2Ban useless unless you configure it to read the real IP from X-Forwarded-For. Check before you trust the bans.
4. Never run your app as root
If you start your Node, Python, or Laravel app with sudo because "it just works that way," stop. You have handed every dependency, every NPM package, every Composer library full root on your server. One compromised supply chain attack (the LiteLLM attack earlier this year is a recent reminder) and the attacker owns everything.
Create a dedicated, unprivileged user for the app:
sudo adduser --system --group --no-create-home appuser
sudo chown -R appuser:appuser /var/www/myapp
Then run your app under that user. Systemd unit files make this trivial:
[Service]
User=appuser
Group=appuser
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node server.js
If appuser gets compromised, the attacker has the app's files and the app's database credentials. That is bad. But they cannot install rootkits, modify SSH config, read other users' files, or pivot to other services on the box. The blast radius is contained to one app.
This is also true on Docker. USER node or USER 1000 in your Dockerfile, not the default root. Containers are not a security boundary by default. Treat them like processes that need an unprivileged user.
5. Put a reverse proxy in front of your app
Your Node app on port 3000 is happy serving HTTP on a single thread. Then 10 bots discover it, hammer it with malformed requests, and your event loop is gone. Reverse proxies (Nginx, Caddy, Traefik) solve this by sitting between the internet and your app, handling the messy parts.
What you actually get:
- TLS termination. Caddy gives you HTTPS with a few lines of config and auto-renewing Let's Encrypt certs. Nginx with Certbot takes 5 minutes. Stop letting your Node process handle SSL.
- Header hardening.
Strict-Transport-Security,X-Frame-Options,Content-Security-Policy, all added at the proxy layer without touching your app. - Rate limiting.
limit_reqin Nginx caps how often a single IP can hit your/loginendpoint. Combined with Fail2Ban, this kills most credential-stuffing attacks before they reach your app. - Connection buffering. Slow clients (
Slowloris-style attacks) eat proxy connections, not your app's. Nginx absorbs them far better than your Node or Express server, especially once you set sensibleclient_header_timeout,client_body_timeout, andlimit_connvalues. Nginx alone is not a magic fix, but it changes the failure mode from "app down" to "a few proxy connections busy."
Caddy is the lowest-friction option for small teams. A few lines in a Caddyfile and you have HTTPS, HTTP/2, and reasonable defaults. Nginx is the boring, battle-tested choice if you need fine control. Pick one. Do not run your app directly on port 80 or 443.
What this still does not protect you from
These five things stop the bots and the lazy. They do not stop:
- A SQL injection in your code (write parameterised queries).
- A leaked
.envin a public GitHub repo (audit your commits). - A compromised developer laptop with SSH keys on it (use a passphrase, rotate keys).
- A supply chain attack in a dependency (lock your versions, watch CVE feeds).
- A determined attacker with a zero-day for your stack.
Hardening is the floor. Application security is a separate problem that lives in your code, your CI pipeline, and your dependency hygiene. We have written about vibe coding security risks and supply chain attacks on AI tools before. Read those if you want the application-layer view.
The PDPA angle
If your Malaysian business stores any personal data on this server (names, IC numbers, phone, email, anything that identifies a person), PDPA is part of the conversation whether you like it or not. The 2024 amendment introduced a 72-hour breach notification window for incidents that meet the threshold, and "we left root SSH open" is not a defence the regulator finds sympathetic.
Basic hardening is not a compliance silver bullet. But a breach caused by skipping it is one of the fastest ways to trigger a disclosure obligation and the reputational fallout that comes with it. Treat the 30 minutes this takes as the cheapest insurance you will ever buy.
What we think
We have audited a lot of Malaysian SME and startup deployments. The pattern is consistent: clean application code, weak server config. Devs are taught how to write SQL safely but rarely taught how to deploy safely. Most security incidents we see for early-stage companies do not start with a clever exploit. They start with port 22 open to the world, password login enabled, an attacker getting in via root:admin123, and 6 weeks of slow lateral movement nobody noticed.
The fix is unglamorous. It is also fast. A new VPS to production-hardened in 30 minutes if you know what you are doing. The first time, give yourself an hour and a checklist.
These 5 things will not make your server invincible. They will make attackers move on to easier targets. That is honestly the goal.
Want a second pair of eyes on your production setup before you ship? Our cybersecurity team does this kind of review regularly. WhatsApp us or drop a note and we will run through your config. No sales pitch.




