Hardening a Linux Web Server on AWS EC2 — From Manual to Fully Automated
A hands-on walkthrough of launching, securing, and automating a production-grade Ubuntu web server on AWS EC2. Covers custom VPCs, security groups, Nginx reverse proxying, systemd services, Fail2ban, user-data bootstrapping, and full AWS CLI automation.
Spinning up an EC2 instance from the AWS Console is easy. Running a hardened, production-grade server that you can deploy and tear down with a single command — that's where real cloud engineering begins.
This guide walks through the three-phase approach I used to go from a manually configured web server to a fully automated infrastructure lifecycle.
The Architecture
A Flask application runs on port 8080 behind a Nginx reverse proxy on port 80. The server lives inside a custom VPC with a public subnet, internet gateway, and a hardened security group that allows SSH only from your IP.
Your Browser → CloudFront / Internet → EC2 (Nginx :80) → Flask App (:8080)
---
Phase 1: Manual Deep Dive
Before automating anything, it's critical to understand every component by building it by hand.
Custom VPC & Networking
If your AWS account doesn't have a default VPC, you need to wire up the networking manually:
1. VPC: CIDR 10.0.0.0/16
2. Public Subnet: 10.0.1.0/24
3. Internet Gateway: Attach to the VPC
4. Route Table: Add a 0.0.0.0/0 route pointing to the IGW, associate with the public subnet
Hardened Security Group
The security group is the first line of defense. The key principle: restrict SSH to your IP only.
| Rule | Port | Source |
|---|---|---|
| SSH | 22 | My IP (not 0.0.0.0/0) |
| HTTP | 80 | 0.0.0.0/0 |
| HTTPS | 443 | 0.0.0.0/0 |
Side effect: Restricting SSH to your IP blocks the AWS Console's EC2 Instance Connect button — it connects from AWS's own IP ranges. This is actually proof your firewall rules are working correctly.
IAM Role for EC2
Attach an IAM role to the instance with two managed policies:
CloudWatchAgentServerPolicy — Ship logs and metrics to CloudWatchAmazonSSMManagedInstanceCore — Enable browser-based Session Manager access (a secure SSH alternative)Configuring Flask + Nginx + systemd
After SSH-ing into the instance, the setup follows a clean pattern:
1. Install dependencies:
sudo apt update -y && sudo apt upgrade -y
sudo apt install -y python3 python3-venv nginx fail2ban
2. Run Flask as a systemd service (not a bare python app.py):
[Unit]
Description=Gunicorn instance to serve Flask Web App
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu
ExecStart=/home/ubuntu/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8080 app:app
Restart=always
[Install]
WantedBy=multi-user.target
3. Configure Nginx as a reverse proxy:
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The Gunicorn + Nginx combo is the production standard. Gunicorn is a WSGI server that handles multiple worker processes; Nginx handles the public-facing network layer, TLS termination, and request buffering.
---
Phase 2: Zero-Touch Bootstrap with User Data
The problem with Phase 1 is that you SSH in and run commands manually — not reproducible. AWS EC2's user data feature solves this: pass a bash script that runs as root on the very first boot.
The user-data.sh script automates the entire Phase 1 setup:
#!/bin/bash
set -e
exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1
apt update -y && apt upgrade -y
apt install -y python3 python3-pip python3-venv nginx fail2ban git
Clone the app from GitHub
git clone https://github.com/pratiksanganii/cloud-engineering.git /home/ubuntu/app
cd /home/ubuntu/app/A02-ec2-linux-server
Set up virtual environment and dependencies
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt gunicorn
Create and enable systemd service
... (writes service file, enables Nginx reverse proxy)
systemctl daemon-reload
systemctl start flask-app
systemctl enable flask-app
After launching an instance with this user data, visiting the public IP in a browser shows the running Flask dashboard — without a single SSH command.
Tip: If you need to debug user data execution, SSH in and run cat /var/log/user-data.log.
---
Phase 3: Full AWS CLI Automation
The final evolution: abandon the console entirely. Two idempotent bash scripts manage the complete infrastructure lifecycle.
deploy.sh — Provision Everything
The deployment script handles the full lifecycle:
#!/bin/bash
set -euo pipefail
Dynamically fetch your current public IP for SSH allowlisting
MY_IP=$(curl -s https://checkip.amazonaws.com)/32
Locate existing VPC and Subnet
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=project2-vpc" \
--query 'Vpcs[0].VpcId' --output text)
Create Security Group
SG_ID=$(aws ec2 create-security-group \
--group-name "project2-web-server-sg" \
--description "Web server SG" \
--vpc-id "$VPC_ID" \
--query 'GroupId' --output text)
Add inbound rules
aws ec2 authorize-security-group-ingress --group-id "$SG_ID" \
--ip-permissions "[{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"IpRanges\":[{\"CidrIp\":\"$MY_IP\"}]}]"
Launch EC2 instance with user-data
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t2.micro \
--key-name "project2-key" \
--security-group-ids "$SG_ID" \
--user-data "fileb://user-data.sh" \
--query 'Instances[0].InstanceId' --output text)
Thefileb://prefix for--user-datais critical on Windows. Without it, PowerShell passes the file content as a UTF-16 encoded string, which the Linux instance cannot parse. Thefileb://flag forces binary read mode, ensuring correct encoding.
cleanup.sh — Tear Down in Dependency Order
Infrastructure deletion must happen in reverse-dependency order to avoid AWS errors:
1. Terminate EC2 instance
2. Wait for full termination (aws ec2 wait instance-terminated)
3. Delete Security Group
4. Delete Key Pair
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 "project2-key"
---
Key Takeaways
python app.py dies when you close your SSH session. A systemd service auto-restarts on crashes and survives reboots.user-data.sh is reliable, you can launch a fully configured server in minutes with no manual steps.fileb:// is not optional on Windows. The fileb:// prefix for the AWS CLI --user-data flag is a common Windows gotcha that silently breaks user-data execution.