Skip to content

Hardening a Linux Server

This activity puts into practice the concepts from the System Security and Hardening lecture. You will apply the Linux hardening checklist to a real server: configure SSH key authentication, close unnecessary ports with a default-deny firewall, install and configure Fail2Ban, and create a least-privilege deploy user. By the end, you will have a server whose attack surface is substantially smaller than the default Ubuntu configuration, and you will be able to describe what each change closes and why it matters.

  • An EC2 instance running Ubuntu 26.04 that you can SSH into
  • Your SSH keypair (e.g., cs312.pem) that you used to launch the instance
  • A terminal on your local machine with SSH access to the instance
  • The instance’s public IP address

Before hardening anything, document what the server looks like right now. The goal is to establish a baseline: what is listening, who can log in, and what the SSH configuration currently allows. You will compare against this at the end.

  1. Connect to your instance:

    Connect to Your Instance
    ssh ubuntu@YOUR_INSTANCE_IP

    You should land at a prompt showing your username and hostname, something like ubuntu@ip-172-31-4-23:~$. Note both; you will use the hostname later.

  2. Check what services are listening on the network:

    Check What Services Are Listening on the Network
    sudo ss -tlnp

    ss is the modern replacement for netstat. The flags mean: -t TCP only, -l listening sockets only, -n numeric addresses (no DNS lookups), -p show the process name. The -p flag requires sudo because process ownership information is restricted to root; without it, the Process column will be empty. You will see at minimum sshd on port 22. On a fresh instance you may also see systemd-resolved on 127.0.0.53 (local DNS) and nothing else. Make a note of every entry and its port.

  3. Check the current SSH configuration:

    Inspect SSH Configuration
    sudo sshd -T | grep -E "passwordauthentication|permitrootlogin|allowusers|pubkeyauthentication"

    sshd -T prints the effective sshd configuration after all includes and defaults are resolved. This is more reliable than reading sshd_config directly because it shows the actual values in use. On an Ubuntu cloud image on EC2 you will typically see passwordauthentication no and permitrootlogin prohibit-password. You will see only three lines, not four: allowusers does not appear when no AllowUsers directive is configured, because the absence means all accounts are implicitly permitted. You will add that directive in Part 2 and see it show up then. Note the three values you do see; you will change at least two of them.

  4. List human login accounts:

    List Human Login Accounts
    grep -v nologin /etc/passwd | grep -v false | grep -v sync

    The -v flag inverts the match: instead of showing lines that contain the pattern, it shows lines that do not contain it. Each pipe removes another category of system accounts: accounts whose shell is /usr/sbin/nologin cannot open an interactive session; those with /bin/false as the shell immediately exit on login; sync is a special utility account. What remains are accounts with a real shell that a person could actually log in with. On a fresh Ubuntu instance you will typically see root and ubuntu. Note the list.

  5. Check recent login history and failures:

    Check Recent Login History and Failures
    journalctl -u ssh -n 20 --no-pager

    This shows the 20 most recent entries from the SSH service journal. Look for lines containing Accepted (successful logins) and Failed or Invalid user (failed attempts). On a new instance you may already see automated scan attempts if the instance has been up for more than a few hours.

  6. Check the AppArmor profiles already in place:

    Check the AppArmor Profiles Already in Place
    sudo aa-status

    aa-status reports whether AppArmor is loaded and which profiles are in two modes: enforce mode blocks operations the profile does not allow; complain mode only logs policy violations without blocking them. The exact profile list depends on which packages are installed on your image, so do not worry if your output differs from someone else’s. The important observation is that AppArmor is active and already confining at least some software on the system.


AWS EC2 instances use key pairs for the initial login, which means you already have a key. This section ensures key-based auth is configured correctly and adds a dedicated deploy user whose permissions you will control explicitly.

  1. Verify your key-based login is working. Before making any changes, open a second terminal and confirm you can SSH in again with your current key:

    Verify Your Key-based Login Is Working
    ssh -i ~/.ssh/your-key.pem ubuntu@YOUR_INSTANCE_IP

    Keep this second session open for the rest of Part 2. If a configuration change locks you out, you have a recovery session already in place.

  2. Create a dedicated deploy user and grant it full passwordless sudo:

    Create a Dedicated Deploy User and Grant It Full
    sudo adduser --disabled-password --gecos "" deploy
    echo "deploy ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/deploy

    adduser --disabled-password creates the account, home directory, and default shell without prompting for a password. The --gecos "" flag skips the name and contact fields. The second command writes an initial sudoers rule that gives deploy full passwordless sudo, matching what the ubuntu user has. You will replace this broad rule with a scoped one in Part 5; for now, deploy needs to be able to run any sudo command to complete the firewall and Fail2Ban steps while ubuntu is locked out.

  3. Add your public key to the deploy user’s authorized keys:

    Add Your Public Key to the Deploy User's Authorized Keys
    sudo mkdir -p /home/deploy/.ssh
    sudo cp /home/ubuntu/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
    sudo chown -R deploy:deploy /home/deploy/.ssh
    sudo chmod 700 /home/deploy/.ssh
    sudo chmod 600 /home/deploy/.ssh/authorized_keys

    authorized_keys is the file sshd reads to determine which public keys are permitted to authenticate for an account. Each line is one public key. When you connect with SSH, the server checks whether your private key matches any public key in this file; if it does, authentication succeeds without a password. You are copying the same public key that already lets you log in as ubuntu to the deploy account. The 700 and 600 permissions are required by sshd: if the .ssh directory or authorized_keys file is writable or broadly readable, sshd may reject it for security reasons.

  4. Test the deploy login from your second terminal:

    Test the Deploy Login from Your Second Terminal
    ssh -i ~/.ssh/your-key.pem deploy@YOUR_INSTANCE_IP

    You should land at a deploy@hostname prompt. If this does not work, do not proceed to the next step; the sshd_config changes below will restrict logins.

  5. Harden sshd_config:

    Harden Sshdconfig
    sudo vim /etc/ssh/sshd_config

    Find or add these four directives. If a line exists with a different value, change it. If it is commented out with #, uncomment and set it:

    Password Authentication Setting
    PasswordAuthentication no
    PermitRootLogin no
    PubkeyAuthentication yes
    AllowUsers deploy

    Save and exit.

  6. Restart the SSH service and observe the lockout:

    Restart the SSH Service and Observe the Lockout
    sudo systemctl restart ssh
    sudo sshd -T | grep -E "passwordauthentication|permitrootlogin|allowusers|pubkeyauthentication"

    The output should now show allowusers deploy. On Ubuntu, the systemd unit is named ssh even though the daemon binary is sshd and the configuration file is sshd_config. From your second terminal, try connecting as ubuntu:

    Connect with SSH
    ssh -i ~/.ssh/your-key.pem ubuntu@YOUR_INSTANCE_IP

    The connection will be rejected. Check the journal from your deploy session to see the rejection logged:

    View System Logs
    sudo journalctl -u ssh -n 5 --no-pager

    You will see a line noting that ubuntu was not permitted.


With SSH secured, you will configure UFW (Uncomplicated Firewall) to default-deny all inbound traffic and explicitly allow only the ports this server needs.

  1. Set the default policies:

    Set the Default Policies
    sudo ufw default deny incoming
    sudo ufw default allow outgoing

    This does not activate the firewall yet. You are staging the rules first so there is no moment where the firewall is active but SSH is not yet allowed.

  2. Allow SSH with rate limiting:

    Allow SSH with Rate Limiting
    sudo ufw limit 22/tcp

    The limit rule allows up to six connection attempts from a single IP within 30 seconds, then blocks that IP temporarily. It is a lighter version of Fail2Ban that works at the network level rather than the application log level.

  3. Allow web traffic (add only the ports your server actually uses):

    Allow HTTP and HTTPS
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp

    Port 80 is HTTP (unencrypted web traffic) and port 443 is HTTPS (TLS-encrypted web traffic). If your server does not serve web traffic, skip these. Only open ports for services that are actually running.

  4. Enable the firewall and verify:

    Enable the Firewall and Verify
    sudo ufw enable
    sudo ufw status verbose

    The output should show:

    Part 3: Configure the Firewall Output
    Status: active
    Logging: on (low)
    Default: deny (incoming), allow (outgoing), disabled (routed)
    New profiles: skip
    To Action From
    -- ------ ----
    22/tcp LIMIT IN Anywhere
    80/tcp ALLOW IN Anywhere
    443/tcp ALLOW IN Anywhere
    22/tcp (v6) LIMIT IN Anywhere (v6)
    80/tcp (v6) ALLOW IN Anywhere (v6)
    443/tcp (v6) ALLOW IN Anywhere (v6)

    If you skipped ports 80 and 443, you will see only the SSH LIMIT IN rules. In either case, if your SSH session stays active, the firewall is correctly configured.

  5. Confirm the port state from inside the server:

    Confirm the Port State from Inside the Server
    sudo ss -tlnp

    Compare this output to the list you made in Part 1. Any service listening on 0.0.0.0 or :: should have a corresponding UFW allow rule. A service bound only to 127.0.0.1 or ::1 is local-only and does not need an inbound allow rule.


UFW’s rate-limiting rule blocks raw connection attempts. Fail2Ban goes a step further: it reads application logs, identifies failed authentication patterns, and dynamically bans the source IP via a firewall rule.

  1. Install Fail2Ban:

    Install Fail2ban
    sudo apt update
    sudo apt install -y fail2ban
  2. Create a local configuration file:

    Create a Local Configuration File
    sudo vim /etc/fail2ban/jail.local

    Add the following:

    Fail2Ban SSH Jail
    [sshd]
    enabled = true
    port = ssh
    filter = sshd
    backend = systemd
    maxretry = 5
    findtime = 600
    bantime = 3600
    banaction = ufw

    Save and exit. This configures the sshd jail: if the same IP fails authentication five times within 600 seconds (10 minutes), it is banned for 3600 seconds (one hour) via a UFW block rule. The backend = systemd directive tells Fail2Ban to read from the systemd journal rather than from /var/log/auth.log directly, which is the correct source on modern Ubuntu.

  3. Enable and start Fail2Ban:

    /var/log/auth.log
    sudo systemctl enable --now fail2ban

    enable registers the service to start at every boot. --now starts it immediately. Without enable, the service would run this session but not survive a reboot.

  4. Verify the jail is active:

    Verify the Jail Is Active
    sudo fail2ban-client status sshd

    You should see output like:

    Part 4: Install and Configure Fail2ban Output
    Status for the jail: sshd
    |- Filter
    | |- Currently failed: 0
    | |- Total failed: 3
    | `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
    `- Actions
    |- Currently banned: 0
    |- Total banned: 0
    `- Banned IP list:

    The exact counts will vary. On a quiet new instance they may all be zero; on a public IP you may already see failures from background scans. Check whether the ubuntu rejection from Part 2 appears in Total failed. AllowUsers rejections happen before authentication completes, so depending on the journal pattern Fail2Ban uses, it may or may not have counted it.

  5. Restore ubuntu SSH access now that Fail2Ban is active:

    Restore Ubuntu SSH Access Now That Fail2ban Is Active
    sudo vim /etc/ssh/sshd_config

    Change AllowUsers deploy to:

    AllowUsers Setting
    AllowUsers deploy ubuntu

    Save, exit, and restart the SSH service:

    Restart Service
    sudo systemctl restart ssh

    From your second terminal, confirm that ssh -i ~/.ssh/your-key.pem ubuntu@YOUR_INSTANCE_IP works again. Keep the ubuntu session open; you will use it in Part 6.


Part 5: Least-Privilege Sudoers Configuration

Section titled “Part 5: Least-Privilege Sudoers Configuration”

The deploy user currently has full passwordless sudo access via the broad rule you wrote in Part 2. On a production server, application accounts should have only the specific elevated permissions they actually need.

  1. Replace the broad sudoers rule with a scoped one:

    Replace the Broad Sudoers Rule with a Scoped One
    sudo visudo -f /etc/sudoers.d/deploy

    visudo validates the syntax before saving, preventing a broken sudoers file that locks you out of sudo. The -f flag edits the file in /etc/sudoers.d/ directly rather than the main sudoers file. Replace the existing NOPASSWD: ALL line with this single line:

    Scoped sudoers Rule
    deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart fail2ban, /usr/bin/fail2ban-client status sshd

    The rule format is user host=(run-as) [flags]: command-list. Reading this one: deploy is the user the rule applies to; ALL is the host (this machine); (ALL) is the user to run as (root in practice); NOPASSWD: is the flag that suppresses the password prompt; the command list at the end specifies the exact executables, and in the second case the exact arguments, that are allowed. Listing full paths prevents a compromised user from putting a malicious binary named systemctl or fail2ban-client earlier in $PATH. This grants deploy permission to restart Fail2Ban and inspect the sshd jail, and nothing else. Save and exit.

  2. Verify the restriction works:

    From your deploy session, test that the allowed command succeeds:

    Verify the Restriction Works
    sudo fail2ban-client status sshd

    You should see the sshd jail status. Then test that a disallowed command fails:

    Update Package Lists
    sudo apt update

    sudo should report that deploy is not allowed to run the command. The restriction is working. A compromised deploy account still has that user’s normal access, but it no longer has a general-purpose sudo path to root.


You have made five distinct changes to this server. Run these verification steps from your ubuntu session; the deploy account’s sudo is now scoped to Fail2Ban only and cannot run the system-level commands below.

  1. Confirm the SSH configuration:

    Verify Hardened SSH Settings
    sudo sshd -T | grep -E "passwordauthentication|permitrootlogin|allowusers|pubkeyauthentication"

    All four lines should show the hardened values: passwordauthentication no, permitrootlogin no, pubkeyauthentication yes, allowusers deploy ubuntu.

  2. Confirm the firewall rules:

    Confirm the Firewall Rules
    sudo ufw status verbose
  3. Confirm Fail2Ban is running:

    Confirm Fail2ban Is Running
    sudo fail2ban-client status sshd
  4. Check open ports one final time:

    Check Open Ports One Final Time
    sudo ss -tlnp

    The only ports visible should be ones with corresponding UFW allow rules.

  5. Create a timestamped verification entry:

    Create a Timestamped Verification Entry
    echo "Hardened by $(whoami) on $(hostname) at $(date)" | sudo tee /var/log/cs312-hardened.log
    cat /var/log/cs312-hardened.log

You have applied the core Linux hardening checklist to a real server. The natural next challenge is to compare your manual hardening work against an automated audit. Install Lynis with sudo apt update && sudo apt install -y lynis, then run sudo lynis audit system and compare its findings against the changes you just made: which recommendations did you already address, and which are still outstanding?

If you want to go deeper into the packet-filtering layer, start with sudo ufw show raw to see the rules UFW is managing on the host. Then read the nftables documentation and compare that lower-level model with UFW’s simplified interface. The useful exercise is to map each rule you added in this activity to the lower-level objects that actually enforce it.