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.
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
SecurityNever open port 22 to
0.0.0.0/0. Restrict it to your own IP with a/32address. If your home IP changes later, update the rule instead of widening it.
TipCheck 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
export AWS_REGION=eu-west-1 # An ed25519 key pair; AWS keeps the public half, you keep the private fileaws ec2 create-key-pair --key-name linux-server-key --key-type ed25519 \ --query KeyMaterial --output text > ~/.ssh/linux-server-key.pemchmod 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 IPaws ec2 authorize-security-group-ingress --group-id "$SG_ID" \ --protocol tcp --port 22 --cidr "${MY_IP}/32" # HTTP from anywhereaws ec2 authorize-security-group-ingress --group-id "$SG_ID" \ --protocol tcp --port 80 --cidr 0.0.0.0/0echo "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.
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 MistakeClosing the terminal and losing the
$SG_ID,$INSTANCE_IDand$SERVER_IPvariables. Keep them in the same shell, or look them up again withaws 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.
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.
sudo apt update && sudo apt upgrade -ysudo apt install -y curl git htop # A quick look at the machineuname -rdf -h /free -hlast -n 5If 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
sudo apt install -y unattended-upgradessudo dpkg-reconfigure --priority=low unattended-upgradesChoose 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 |
TipKeep 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.
sudo useradd --system --create-home --shell /usr/sbin/nologin appuserid appuserHarden 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.
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf > /dev/null << 'EOF'PermitRootLogin noPasswordAuthentication noKbdInteractiveAuthentication noPubkeyAuthentication yesMaxAuthTries 3EOF # Check the syntax BEFORE applyingsudo sshd -t && echo "config OK" # Show the settings SSH will really usesudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries)' sudo systemctl reload sshExpected 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.
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 MistakeRestarting SSH with a typo in the config and losing access, because the first session was closed. Always run
sshd -tfirst, usereload, 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.
sudo ufw default deny incomingsudo ufw default allow outgoing # "limit" allows SSH but blocks an address that makes too many attemptssudo ufw limit 22/tcpsudo ufw allow 80/tcpsudo ufw allow 443/tcp sudo ufw --force enablesudo ufw status verboseExpected 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 |
TipThe 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 MistakeEnabling 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.
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.shless /tmp/nodesource_setup.shsudo -E bash /tmp/nodesource_setup.shsudo apt install -y nodejsnode --versionVersion 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
sudo mkdir -p /opt/webappsudo chown appuser:appuser /opt/webappsudo -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 directlyapp.listen(3000, '127.0.0.1', () => { console.log('listening on 127.0.0.1:3000');});EOFsudo chown appuser:appuser /opt/webapp/app.jsThe 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
sudo tee /etc/systemd/system/webapp.service > /dev/null << 'EOF'[Unit]Description=Demo web applicationAfter=network.target [Service]User=appuserWorkingDirectory=/opt/webappExecStart=/usr/bin/node /opt/webapp/app.jsEnvironment=NODE_ENV=productionRestart=alwaysRestartSec=2NoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=true [Install]WantedBy=multi-user.targetEOF sudo systemctl daemon-reloadsudo systemctl enable --now webappsudo systemctl status webapp --no-pagercurl -s http://127.0.0.1:3000/healthExpected 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 MistakeRunning the app as
rootor asubuntu"just to make it work". A flaw in the app then hands an attacker an administrator account. If the app fails underappuser, readsudo journalctl -u webapp -n 30to 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.
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/webappsudo rm -f /etc/nginx/sites-enabled/default sudo nginx -tsudo systemctl reload nginxsudo systemctl enable nginxExpected result: nginx -t reports the syntax is ok and the test is successful.
Now test from your laptop, not the server:
curl -i http://"$SERVER_IP"/curl -s http://"$SERVER_IP"/healthExpected 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.
TipTo 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 MistakeA
502 Bad Gatewayfrom Nginx means it could not reach the app. Check that the service is running withsystemctl status webapp, and that theproxy_passaddress 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
# 1. Password login is refused (expect: Permission denied (publickey))ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ubuntu@"$SERVER_IP" # 2. Root login is refusedssh -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 Nginxcurl -s http://"$SERVER_IP"/healthTests on the server
# SSH settings really in effectsudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication)' # Firewall rulessudo ufw status verbose # The app listens on localhost only: look for 127.0.0.1:3000, not 0.0.0.0:3000sudo ss -tlnp | grep -E ':(80|3000)\b' # systemd restarts the app after a crashsudo systemctl kill -s SIGKILL webappsleep 4systemctl 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 -5Expected 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
appuserunder 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.
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-keyrm ~/.ssh/linux-server-key.pemPortfolio 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.
SecurityHardening 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.