Back to articles
AWS / DevOpsJuly 7, 20266 min read

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.

code read-only
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 CloudWatch
  • AmazonSSMManagedInstanceCore — 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:

    bash read-only
    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):

    ini read-only
    [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:

    nginx read-only
    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:

    bash read-only
    #!/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:

    bash read-only
    #!/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)
    The fileb:// prefix for --user-data is critical on Windows. Without it, PowerShell passes the file content as a UTF-16 encoded string, which the Linux instance cannot parse. The fileb:// 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

    bash read-only
    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

  • Understand before you automate. Going manual first reveals why each AWS resource exists — VPCs, route tables, security groups, and IAM roles each solve a specific security or connectivity problem.
  • systemd over bare processes. A bare python app.py dies when you close your SSH session. A systemd service auto-restarts on crashes and survives reboots.
  • Fail2ban for brute-force protection. Even with SSH restricted to your IP, Fail2ban is a good defense-in-depth layer for HTTP endpoints.
  • User data = zero-touch provisioning. Once your 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.
  • P
    Pratik Sangani
    Backend Developer & Architect
    Get in touch