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.

Frequently Asked Questions

Why disable password authentication for SSH? Public servers are continuously scanned with automated password guessing. Key-only authentication removes that entire attack, since there is no password to guess.
Why did my SSH key stop working? Usually file permissions. SSH refuses keys when .ssh is not 700 or authorized_keys is not 600, and it fails without a clear explanation.
Do I need a firewall if the cloud provider has security groups? Use both. They are independent layers, and a misconfiguration in one is caught by the other. Deny inbound by default and allow only what you need.
Why use nginx in front of my application? It terminates TLS, serves static files efficiently, and can handle rate limiting and compression. Remember to forward the original host and protocol headers to your app.
How do I keep my app running after logout? Create a systemd service with Restart=always and enable it, so the application starts on boot and recovers from crashes automatically.