What you'll learn
Quick Answer
Create a non-root user with sudo, switch SSH to key-only authentication and disable root login, enable a firewall allowing only what you need, then install your web server and TLS certificate.
Stop using root
A new server usually gives you root, which can do anything with no confirmation and no audit trail. A mistyped rm as root is unrecoverable.
adduser deploy
usermod -aG sudo deploy # Debian/Ubuntu
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
Those permissions are not optional — SSH refuses keys with loose permissions, and this is the most common reason key login mysteriously fails.
Open a second terminal and confirm you can log in as deploy before changing anything else. Locking yourself out of a remote server with no console access is a genuine possibility, and it costs an afternoon.
Harden SSH
Password authentication on a public server is guessed continuously by automated scanners. Switch to keys only.
In /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
sudo sshd -t # test config BEFORE restarting
sudo systemctl restart ssh
sshd -t validates the file. Restarting with a syntax error stops the service, and if that is your only access, the server is gone.
Those two settings remove essentially all opportunistic SSH attacks — with no password to guess and root login disabled, the standard automated attack has nothing to work with.
Optionally add fail2ban, which bans addresses after repeated failures. Changing the SSH port reduces log noise but is not real security. See SSH explained for keys themselves.
Firewall: deny by default
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
sudo ufw status
Deny everything inbound, then allow only what is needed. Note allow OpenSSH comes before enable — enabling a deny-all firewall without permitting SSH disconnects you immediately.
The service most often left exposed by accident is a database. A database bound to 0.0.0.0 with a weak password is found by scanners quickly, and it is a common source of real breaches. Bind it to localhost, or to a private network address only, and let the firewall block the port regardless.
Cloud providers also have their own firewall layer — security groups or equivalent. Use both; they are independent.
Web server and TLS
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx
sudo systemctl enable --now nginx
Nginx typically sits in front of your application as a reverse proxy — it handles TLS, serves static files efficiently, and forwards everything else:
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Those forwarded headers matter — without them your application sees every request as coming from localhost over HTTP, which breaks logging, rate limiting and any redirect-to-HTTPS logic.
Certificates are free and automated:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
Certbot obtains the certificate, edits the nginx config, and installs a renewal timer. Verify renewal works with certbot renew --dry-run rather than discovering it failed when the certificate expires — see SSL/TLS certificates.
Keeping the application running
Starting your app in an SSH session means it dies when you disconnect. Use a service manager so it starts on boot and restarts on crash:
[Unit]
Description=My App
After=network.target
[Service]
User=deploy
WorkingDirectory=/home/deploy/app
ExecStart=/home/deploy/app/venv/bin/python app.py
Restart=always
EnvironmentFile=/home/deploy/app/.env
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now myapp
sudo journalctl -u myapp -f # follow the logs
Restart=always means a crash is recovered automatically, and enable means it survives a reboot.
Two finishing touches: enable unattended security updates so patches apply without you remembering, and set up off-server backups — a backup on the same machine protects against nothing that actually happens. Then verify a restore works, because an untested backup is a hope rather than a plan.
