Skip to main content

Set Up and Secure a Linux Server on AWS EC2

Launch an Ubuntu server on AWS EC2, lock down SSH and the firewall, and run a Node.js app as a systemd service behind Nginx.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project launches an Ubuntu server on AWS EC2 and hardens it step by step: a firewall at two layers, SSH that accepts keys only, a dedicated non-root user for the application, and a Node.js web app that runs as a systemd service behind an Nginx reverse proxy. By the end only ports 22 (from your IP) and 80 are reachable, and the app itself listens on localhost where the internet cannot touch it.

A brand-new server is exposed the moment it gets a public IP. Automated scanners find it within minutes and try thousands of passwords against SSH. Hardening is the habit of closing every door you do not need before anyone knocks.

flowchart TD U[Your laptop] -->|SSH, key only| SG[Security group] W[Internet users] -->|HTTP| SG SG --> F[UFW firewall] F --> N[Nginx on port 80] N -->|localhost only| A[Node.js app
127.0.0.1:3000] S[systemd] -.keeps running.-> A classDef user fill:#475569,color:#fff classDef guard fill:#EF4444,color:#fff classDef proxy fill:#326CE5,color:#fff classDef app fill:#10B981,color:#fff classDef sys fill:#8B5CF6,color:#fff class U,W user class SG,F guard class N proxy class A app class S sys

Defence in depth

Layer Tool What it does
Network edge AWS security group Only your IP can reach SSH, anyone can reach port 80
Host firewall UFW A second, independent set of allow rules on the server
Access SSH keys, no root login Nothing to brute-force
Application Dedicated user, systemd limits A bug in the app cannot become a root shell
Exposure App bound to localhost The app is reachable only through Nginx

What you need first

  • An AWS account and the AWS CLI v2 configured with aws configure
  • A terminal on Linux, macOS or WSL
  • About an hour, plus a few cents of EC2 usage; you delete everything at the end
Security

Never open port 22 to 0.0.0.0/0. Restrict it to your own IP with a /32 address. If your home IP changes later, update the rule instead of widening it.

Tip

Check your account's free-tier terms before launching, because they differ between accounts. Delete the instance when you finish so it cannot keep billing you.

Milestone 1: Create the Network Rules and Launch the Instance

This milestone creates a key pair and a security group, then launches the server. The order matters: the security group must exist before the instance that uses it. The security group is the first firewall, enforced by AWS before traffic ever reaches your server.

Create the key pair and security group

Bash
export AWS_REGION=eu-west-1
# An ed25519 key pair; AWS keeps the public half, you keep the private file
aws ec2 create-key-pair --key-name linux-server-key --key-type ed25519 \
--query KeyMaterial --output text > ~/.ssh/linux-server-key.pem
chmod 400 ~/.ssh/linux-server-key.pem
MY_IP=$(curl -s https://checkip.amazonaws.com)
SG_ID=$(aws ec2 create-security-group --group-name linux-server-sg \
--description "Linux server hardening project" \
--query GroupId --output text)
# SSH only from your own IP
aws ec2 authorize-security-group-ingress --group-id "$SG_ID" \
--protocol tcp --port 22 --cidr "${MY_IP}/32"
# HTTP from anywhere
aws ec2 authorize-security-group-ingress --group-id "$SG_ID" \
--protocol tcp --port 80 --cidr 0.0.0.0/0
echo "Security group: $SG_ID"

Launch the server

AMI IDs differ per region and go out of date, so ask AWS for the current Ubuntu 24.04 image instead of pasting an ID.

Bash
AMI_ID=$(aws ssm get-parameter \
--name /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
--query Parameter.Value --output text)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t3.micro \
--key-name linux-server-key \
--security-group-ids "$SG_ID" \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=linux-server}]' \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
SERVER_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "Server IP: $SERVER_IP"

Expected result: an instance ID and a public IP. If t3.micro is not available in your account or region, list eligible types with aws ec2 describe-instance-types --filters Name=free-tier-eligible,Values=true --query 'InstanceTypes[].InstanceType' and use one of them.

Common Mistake

Closing the terminal and losing the $SG_ID, $INSTANCE_ID and $SERVER_IP variables. Keep them in the same shell, or look them up again with aws ec2 describe-instances. The public IP also changes if you stop and start the instance, unless you attach an Elastic IP.

Milestone 2: Connect and Update the Server

This milestone makes your first connection and brings the system up to date. A server built from an image is already a few weeks old, so installing security updates is the first thing you do on any new machine.

Bash
ssh -i ~/.ssh/linux-server-key.pem ubuntu@"$SERVER_IP"

Answer yes to the host key question. You are now logged in as ubuntu, the default administrator, who can run commands as root with sudo.

Bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git htop
# A quick look at the machine
uname -r
df -h /
free -h
last -n 5

If the upgrade mentions a new kernel and asks for a restart, run sudo reboot, wait a minute, and connect again.

Turn on automatic security updates

Bash
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Choose Yes. The server now installs security patches by itself, which closes the gap between a fix being published and you noticing it.

Command What it tells you
uname -r The running kernel version
df -h / Free disk space on the root volume
free -h Memory in use and available
last -n 5 The five most recent logins
Tip

Keep this terminal open for the whole project. In the next milestone you change how SSH works, and an open session is your safety net if you make a mistake.

Milestone 3: Create an App User and Harden SSH

This milestone separates two jobs that should never share an identity: administering the server and running the application. You create a locked-down user for the app, then change SSH so that only key logins work and root cannot log in at all.

Create a user that can run the app but not log in

A system user with the nologin shell cannot open an interactive session, so even a stolen key or password would be useless for it. It also gets no sudo rights.

Bash
sudo useradd --system --create-home --shell /usr/sbin/nologin appuser
id appuser

Harden the SSH daemon

Ubuntu cloud images ship a file called 50-cloud-init.conf in /etc/ssh/sshd_config.d/ that sets PasswordAuthentication yes. SSH uses the first value it reads for each setting, and files in that folder load in alphabetical order, so a file named 10-hardening.conf wins. Editing only /etc/ssh/sshd_config would silently do nothing.

Bash
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf > /dev/null << 'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
# Check the syntax BEFORE applying
sudo sshd -t && echo "config OK"
# Show the settings SSH will really use
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries)'
sudo systemctl reload ssh

Expected result: config OK, then permitrootlogin no, passwordauthentication no and the other values you set.

Test from a second terminal

Open a new terminal window and connect again, keeping the first session open.

Bash
ssh -i ~/.ssh/linux-server-key.pem ubuntu@"$SERVER_IP" echo "key login still works"

Expected result: the message prints. Only close the first session once this works.

Common Mistake

Restarting SSH with a typo in the config and losing access, because the first session was closed. Always run sshd -t first, use reload, and test from a second terminal before you leave the original one.

Milestone 4: Turn On the UFW Firewall

The security group already filters traffic at the AWS edge. UFW (Uncomplicated Firewall) adds a second, independent filter on the server itself. If someone mistakenly opens a port in the security group later, the host firewall still says no.

The rule that prevents lockouts: allow SSH before you enable the firewall.

Bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
# "limit" allows SSH but blocks an address that makes too many attempts
sudo ufw limit 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status verbose

Expected result: the status is active, incoming is denied by default, and ports 22 (LIMIT), 80 and 443 are listed. Port 3000 does not appear.

Rule Why
default deny incoming Anything not listed is blocked
limit 22/tcp SSH allowed, repeated rapid failures are throttled
allow 80/tcp Web traffic for Nginx
allow 443/tcp Ready for HTTPS if you add a certificate later
Tip

The two firewalls are separate. A request must be allowed by the security group and by UFW to reach a program. When something cannot connect, check both.

Common Mistake

Enabling UFW before allowing SSH. The firewall then drops your own connection. If it happens, the security group still lets you in through the EC2 console's browser-based connect feature, where you can run sudo ufw allow 22/tcp.

Milestone 5: Deploy the Node.js App as a systemd Service

This milestone installs Node.js, creates a small web app, and runs it as a systemd service. systemd is the program that starts and supervises everything on a modern Linux server. A service gets automatic restarts after a crash, starts on boot, and can be locked down with sandbox options.

Install Node.js

NodeSource publishes current Node.js packages for Ubuntu. Download the setup script first instead of piping it straight into a shell, so you can read what it will do.

Bash
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh
sudo -E bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs
node --version

Version 24 was the current LTS release when this project was written. Use the version your own research shows is the current LTS, and change the number in the URL.

Create the app under /opt

Bash
sudo mkdir -p /opt/webapp
sudo chown appuser:appuser /opt/webapp
sudo -u appuser bash -c 'cd /opt/webapp && npm init -y > /dev/null && npm install express'
sudo tee /opt/webapp/app.js > /dev/null << 'EOF'
const express = require('express');
const os = require('os');
const app = express();
app.get('/', (req, res) => {
res.send('<h1>My hardened Linux server is running</h1>');
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy', host: os.hostname() });
});
// 127.0.0.1 means only this machine can connect directly
app.listen(3000, '127.0.0.1', () => {
console.log('listening on 127.0.0.1:3000');
});
EOF
sudo chown appuser:appuser /opt/webapp/app.js

The 127.0.0.1 address is what keeps port 3000 off the network. Even if both firewalls were opened by mistake, the app would not answer outside connections.

Write the service file

Bash
sudo tee /etc/systemd/system/webapp.service > /dev/null << 'EOF'
[Unit]
Description=Demo web application
After=network.target
[Service]
User=appuser
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/app.js
Environment=NODE_ENV=production
Restart=always
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now webapp
sudo systemctl status webapp --no-pager
curl -s http://127.0.0.1:3000/health

Expected result: the service is active (running) and the curl returns a JSON health message.

Option What it does
User=appuser The app runs without root rights
Restart=always systemd restarts it two seconds after any exit
NoNewPrivileges=true The process can never gain extra privileges
ProtectSystem=strict The whole filesystem is read-only to the app
PrivateTmp=true The app gets its own private temporary folder
Common Mistake

Running the app as root or as ubuntu "just to make it work". A flaw in the app then hands an attacker an administrator account. If the app fails under appuser, read sudo journalctl -u webapp -n 30 to find the permission it needs and grant only that.

Milestone 6: Put Nginx in Front as a Reverse Proxy

A reverse proxy accepts connections from the internet and forwards them to an app that is not exposed directly. Nginx is built to face the internet: it handles slow clients, logging and later TLS, while your Node.js process stays hidden on localhost.

Bash
sudo apt install -y nginx
sudo tee /etc/nginx/sites-available/webapp > /dev/null << 'EOF'
server {
listen 80 default_server;
server_name _;
server_tokens off;
access_log /var/log/nginx/webapp-access.log;
error_log /var/log/nginx/webapp-error.log;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/webapp /etc/nginx/sites-enabled/webapp
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl enable nginx

Expected result: nginx -t reports the syntax is ok and the test is successful.

Now test from your laptop, not the server:

Bash
curl -i http://"$SERVER_IP"/
curl -s http://"$SERVER_IP"/health

Expected result: an HTTP 200 with the HTML page, and the JSON health message. The server_tokens off line hides the Nginx version number from the response headers, which gives scanners less to work with.

Tip

To serve HTTPS later, point a domain name at the server and use Certbot to get a free certificate. Ports 80 and 443 are already open for it.

Common Mistake

A 502 Bad Gateway from Nginx means it could not reach the app. Check that the service is running with systemctl status webapp, and that the proxy_pass address and port match the app's.

Validation & Testing

Prove each control works by testing from outside the server, then clean up so nothing keeps billing you.

Tests from your laptop

Bash
# 1. Password login is refused (expect: Permission denied (publickey))
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ubuntu@"$SERVER_IP"
# 2. Root login is refused
ssh -i ~/.ssh/linux-server-key.pem root@"$SERVER_IP"
# 3. Port 3000 is not reachable (expect a timeout)
curl -m 5 http://"$SERVER_IP":3000
# 4. The app works through Nginx
curl -s http://"$SERVER_IP"/health

Tests on the server

Bash
# SSH settings really in effect
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication)'
# Firewall rules
sudo ufw status verbose
# The app listens on localhost only: look for 127.0.0.1:3000, not 0.0.0.0:3000
sudo ss -tlnp | grep -E ':(80|3000)\b'
# systemd restarts the app after a crash
sudo systemctl kill -s SIGKILL webapp
sleep 4
systemctl is-active webapp
# Failed SSH attempts from the internet (this is normal)
sudo journalctl -u ssh --since "1 hour ago" | grep -iE 'invalid user|failed' | tail -5

Expected result: each login test is denied, port 3000 times out, the ss output shows 127.0.0.1:3000, the service reports active after being killed, and the log may show scanners trying to guess usernames. None of those attempts can succeed, because passwords are disabled.

Checklist

  • The server is reachable over SSH only with your key, and root login is blocked
  • UFW is active and only ports 22, 80 and 443 are allowed
  • The security group allows SSH from one IP only
  • The app runs as appuser under systemd and restarts after a crash
  • Nginx serves the app, and the app is not reachable on port 3000 from outside

Clean up

Delete everything you created so it stops costing money.

Bash
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"
aws ec2 wait instance-terminated --instance-ids "$INSTANCE_ID"
aws ec2 delete-security-group --group-id "$SG_ID"
aws ec2 delete-key-pair --key-name linux-server-key
rm ~/.ssh/linux-server-key.pem

Portfolio deliverable

Write a README with the architecture diagram, the output of the tests above, and a short list of what you would add next: fail2ban, HTTPS with Certbot, log shipping and an Elastic IP.

Security

Hardening is never finished. Keep automatic updates on, review who has access, and rebuild servers from a script instead of fixing them by hand. The Terraform project in this track shows how.

Found a mistake, an outdated step or a broken link?