Harden a Linux Server
Spin up your own disposable Linux box (Docker or Vagrant) and take it from a default install to a hardened baseline: non-root sudo user, key-only SSH, a locked-down firewall, a trimmed service surface, and fail2ban watching the door.
Problem
A freshly provisioned Linux server is not safe to expose to the internet. Out of the box it typically ships with: a `root` account reachable over SSH, password authentication enabled, every default service running and listening on every interface, no firewall rules, and no protection against brute-force login attempts. Automated scanners find new hosts within minutes of them going public — you do not get to "harden it later." In this lab you will provision a disposable Linux VM or container that you fully control, then work through a real hardening checklist against it: create a non-root administrative user, disable root SSH login, switch to key-based authentication only, configure a firewall that denies by default, disable or remove services you are not using, and install fail2ban to automatically block hosts that hammer your SSH port. Every step ends with a command you run to **prove** the control is in place — hardening you cannot verify is hardening you cannot trust. > Practice only against a disposable VM/container you control (Docker, Vagrant, > or a throwaway cloud instance you own). Never run these exploratory steps — > especially the "before" checks — against a server you do not own or administer.
Objectives
By the end of this lab you will be able to:
- Provision a disposable Linux host for security practice (Docker container with
SSH, or a Vagrant VM). - Create a non-root user with sudo privileges and confirm it can administer the box.
- Disable root login and password authentication over SSH, switching to key-based auth.
- Configure
ufw(or an equivalent firewall) to deny all inbound traffic by
default and allow only the ports you actually need. - Audit and disable unnecessary running services to shrink the attack surface.
- Install and configure
fail2banto automatically ban hosts that repeatedly
fail SSH login. - Verify every control with a concrete command, not just "it should work now."
Prerequisites
To complete this lab you'll need:
- Docker installed (recommended path: a Linux container with
sshd, e.g. an
Ubuntu image), or Vagrant + VirtualBox/libvirt if you prefer a full VM. - A terminal and an SSH client (
ssh,ssh-keygen) on your host machine. - Root or sudo access inside the disposable VM/container itself (you are
hardening a box you provisioned, not a shared one). - No prior Linux administration experience required, but comfort with a shell
helps.
The mental model: default-deny, minimal surface
Hardening is not a single switch — it is a stack of independent controls, each
closing a different door. The philosophy that ties them together is
default-deny, minimal surface: nothing is allowed unless you explicitly
allowed it, and nothing runs unless you actually need it running.
Attacker's view of a hardened box
│
▼ no root login over SSH (Step 2)
▼ no password auth, key only (Step 3)
▼ firewall denies everything else (Step 4)
▼ fewer services listening (Step 5)
▼ fail2ban bans brute-force IPs (Step 6)
▼
a much smaller, much slower target
None of these controls alone makes a server "secure" — security is layered
(defense in depth). A leaked SSH key still gets in if you have no firewall; an
open firewall port to a vulnerable service still gets exploited if root login is
disabled. You harden every layer because you don't know which one an attacker
will hit first.
Why practice on a disposable box
Getting a step wrong while hardening (e.g. disabling password auth before your
key works) can lock you out of a real server. A disposable container or VM you
can destroy and recreate in seconds removes that fear — break it, learn from it,
tear it down, start over. That is exactly what this lab is for.
How to work through this lab
Do the steps in order — later steps assume earlier ones are done (e.g. you need
your non-root user and SSH keys working before you disable root/password
login, or you will lock yourself out). Each step ends with a verification
command; do not move on until it passes.
Steps
-
Provision a disposable Linux host
You need a real, disposable Linux box with SSH access. The fastest path is a
Docker container runningsshd; the more realistic path is a full VM via
Vagrant. Pick one.Option A — Docker (fastest)
# Build a minimal Ubuntu image with SSH server and a starting root password cat > Dockerfile <<'EOF' FROM ubuntu:22.04 RUN apt-get update && apt-get install -y openssh-server sudo && \ mkdir /var/run/sshd && \ echo 'root:changeme123' | chpasswd && \ sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config EXPOSE 22 CMD ["/usr/sbin/sshd", "-D"] EOF docker build -t hardening-lab . docker run -d --name hardening-lab -p 2222:22 hardening-lab # Confirm you can reach it (password: changeme123) ssh -p 2222 root@localhostOption B — Vagrant (closer to a real VM)
vagrant init ubuntu/jammy64 vagrant up vagrant ssh # confirm you land inside the boxWhichever you pick, you should end this step logged in as root (or via
vagrant sshwith passwordless sudo), with a known way back in if
something goes wrong: for Docker,docker exec -it hardening-lab bash
always gets you a root shell even if SSH breaks; for Vagrant,
vagrant sshsimilarly bypasses your hardening changes.Checkpoint
Run
whoamiandcat /etc/os-releaseinside the box and confirm you're
root on a fresh Ubuntu/Debian host. Keep the "escape hatch" command
(docker execorvagrant ssh) written down somewhere — you'll want it if
a later step goes wrong.Deliverable for this step: a running, disposable Linux host you can SSH
into, plus a documented escape hatch that does not depend on SSH. -
Create a non-root sudo user
Running everything as
rootmeans any compromised process — a bug in your
app, a malicious dependency, a hijacked script — instantly has full control
of the machine. A non-root user withsudogives you the same administrative
power on demand, with an audit trail and a smaller blast radius for
anything that runs withoutsudo.# As root: adduser --disabled-password --gecos "" opslab usermod -aG sudo opslab # Debian/Ubuntu; use 'wheel' group on RHEL/Fedora # Give the new user a password for now (you'll disable password SSH later) passwd opslab # Confirm the sudo group grants what you expect cat /etc/sudoers.d/* /etc/sudoers 2>/dev/null | grep -i sudoSwitch to it and confirm sudo works before you touch SSH config:
su - opslab sudo whoami # should print: rootWhy this order matters
You create and verify the non-root user before locking down root SSH
access in Step 3. If you disable root login first and your new user isn't
working yet, you can lock yourself out entirely (on a real server — on this
disposable box you'd just use your escape hatch and start over, but build
the habit now).Checkpoint
From a fresh terminal (not the root session), run:
ssh -p 2222 opslab@localhost # or `vagrant ssh` equivalent sudo whoami # must print "root" without errorDeliverable for this step: a non-root user that can authenticate and
successfully runsudo whoami, confirmed from a separate login, not just
the session where you created it. -
Key-only SSH, no root login
Passwords are guessable and phishable; SSH key pairs are not. And even a
strong password onrootis one credential away from full compromise —
removing root's ability to log in over SSH entirely means an attacker must
first compromise a named, auditable user and escalate via sudo.Generate and install a key
On your host machine (not inside the box):
ssh-keygen -t ed25519 -f ~/.ssh/hardening_lab -C "hardening-lab" ssh-copy-id -p 2222 -i ~/.ssh/hardening_lab.pub opslab@localhost # or manually: append the .pub contents to # /home/opslab/.ssh/authorized_keys inside the box (mode 600, owned by opslab)Confirm key-based login works before disabling passwords:
ssh -p 2222 -i ~/.ssh/hardening_lab opslab@localhost 'whoami'Lock down sshd_config
Inside the box, edit
/etc/ssh/sshd_config:PermitRootLogin no PasswordAuthentication no ChallengeResponseAuthentication no PubkeyAuthentication yes AllowUsers opslabApply and restart:
sudo sshd -t # validate syntax before restarting sudo systemctl restart sshdCheckpoint — prove both halves
# 1) Key login for opslab still works ssh -p 2222 -i ~/.ssh/hardening_lab opslab@localhost 'whoami' # 2) Password login is refused (should fail / be rejected, not prompt success) ssh -p 2222 -o PubkeyAuthentication=no opslab@localhost # 3) Root login is refused entirely, even with the right password ssh -p 2222 root@localhostIf step 2 or 3 above still lets you in,
sshd_configdidn't reload —
re-checksudo sshd -toutput andsystemctl status sshd.Deliverable for this step:
opslablogs in only with the key,
PasswordAuthenticationandPermitRootLoginare both confirmed off by
an actual failed login attempt, not just by reading the config file. -
Lock down the firewall with ufw
Even with SSH hardened, every other service on the box is still reachable
from anywhere unless a firewall says otherwise.ufw(Uncomplicated
Firewall) gives you a default-deny policy in a few commands.sudo apt-get update && sudo apt-get install -y ufw # Default-deny everything inbound, allow all outbound sudo ufw default deny incoming sudo ufw default allow outgoing # Allow only what you actually need. Rate-limit SSH against brute force. sudo ufw limit 22/tcp comment 'SSH, rate-limited' # Add more only as needed, e.g.: # sudo ufw allow 80/tcp comment 'HTTP' # sudo ufw allow 443/tcp comment 'HTTPS' sudo ufw enableOrder matters inside a container.
sudo ufw enablebefore you've
confirmed your SSH rule is exactly right can drop your own connection. In
Docker specifically,ufwinteracts awkwardly withiptables-based port
mapping — if you're on Docker, treat this step as learning the ufw
workflow and verify withufw status; for a fully enforced firewall in
front of Docker port mappings, a Vagrant/VM target is more realistic.Why
limitinstead ofallowfor SSHufw limit 22/tcpdenies an IP that makes more than 6 connection attempts
within 30 seconds — a cheap first line of defense against brute force,
complementary to (not a replacement for) fail2ban in Step 6.Checkpoint
sudo ufw status verboseConfirm the output shows:
- Default:
deny (incoming),allow (outgoing) - Only the ports you explicitly allowed are listed as
ALLOW/LIMIT - Nothing else is open
Then, from your host machine, try to reach a port you did not open
(e.g.nc -zv localhost 2222should work, but a port you never allowed,
like 9999, should time out or be refused).Deliverable for this step:
ufw status verboseoutput showing a
default-deny inbound policy with an explicit, minimal allowlist — and a
failed connection attempt to a port you did not open, proving the deny
actually holds. - Default:
-
Audit and disable unnecessary services
Every running service is a potential vulnerability, even behind a firewall —
a local privilege-escalation bug in a daemon you never use is still a risk,
and a misconfigured firewall rule later can accidentally expose something
you forgot was running. The rule: if you don't need it, don't run it.Inventory what's listening and what's enabled
# What's actually listening on a network socket right now sudo ss -tulpn # What services are enabled to start at boot systemctl list-unit-files --type=service --state=enabledOn a minimal Ubuntu server image, you'll typically see things like
cron,rsyslog, andsshd— keep those. Watch for anything you didn't
knowingly install: an unused mail transfer agent (exim4,postfix), a
print service (cups), or a database/service bound to0.0.0.0that you
never intended to expose beyondlocalhost.Disable what you don't need
# Example: disable and stop a service you identified as unnecessary sudo systemctl disable --now <service-name> # Confirm it's gone from both "enabled" and "listening" systemctl is-enabled <service-name> # should print "disabled" or error sudo ss -tulpn | grep <service-name> # should print nothingFor anything bound to
0.0.0.0that you do need but only for local use
(e.g. a database you only query from the same host), rebind it to
127.0.0.1in its config instead of relying solely on the firewall — this
is defense in depth: even if a firewall rule is later loosened by mistake,
the service still won't accept remote connections.Checkpoint
sudo ss -tulpnEvery line in the output should be something you can name and justify. If
you can't say why a listening socket exists, that's your next thing to
investigate and likely disable.Deliverable for this step: a
ss -tulpnoutput where every listening
socket is accounted for, plus the list of services you disabled and why. -
Install fail2ban and verify a ban (submission)
ufw limitslows brute force down;fail2banactively bans an offending IP
after repeated failures, watching your logs and updating the firewall for you.
This is your last layer for this lab, and the one you'll prove end-to-end.Install and enable the SSH jail
sudo apt-get install -y fail2ban sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF' [sshd] enabled = true port = 22 filter = sshd logpath = /var/log/auth.log maxretry = 3 findtime = 5m bantime = 15m EOF sudo systemctl enable --now fail2ban sudo systemctl status fail2ban --no-pagerTrigger a real ban
From your host machine, deliberately fail SSH login more than
maxretrytimes against your own disposable box:for i in 1 2 3 4; do ssh -p 2222 -o PubkeyAuthentication=no -o PreferredAuthentications=password \ -o ConnectTimeout=3 opslab@localhost 'echo should-not-reach' || true doneVerify the ban actually took effect
sudo fail2ban-client status sshdYou should see your host's IP (likely
127.0.0.1or the Docker gateway
address) listed under "Banned IP list". Confirm a subsequent connection
attempt is refused outright (connection refused/timeout, not even a
password prompt) untilbantimeelapses, or unban it manually to keep
working:sudo fail2ban-client set sshd unbanip <the-ip>
Submission criteria
Submit when all of the following hold, each backed by the command output
that proves it:-
Non-root sudo user —
sudo whoamisucceeds asopslabfrom a fresh
login (Step 2). -
Key-only SSH, no root — password login and root login both fail;
key login foropslabsucceeds (Step 3). -
Firewall default-deny —
sudo ufw status verboseshowsdeny (incoming)with an explicit, minimal allowlist (Step 4). -
Minimal service surface —
sudo ss -tulpnoutput with every listening
socket accounted for, and the list of services you disabled (Step 5). -
fail2ban working —
sudo fail2ban-client status sshdshowing at
least one IP that got banned by your own deliberate failed-login test
(this step).
Write a short note on what you'd add next for a production box (e.g.
automatic security updates viaunattended-upgrades, centralized log
shipping, an intrusion detection agent) — you don't need to implement it,
just name the next layer. -
Non-root sudo user —