Project 2: A self-healing environment
Survives losing a server and a whole zone, with numbers to prove it.
| What you build | A launch template, an auto scaling group across two zones, a load balancer with HTTPS, a managed database, S3, secrets, logs and alarms. |
| What it proves | It survives losing a server and a whole zone — measured, with numbers. |
On launch night, QuickCart’s single server fell over at 7pm, and the app went down with it. This project builds the opposite: an environment where losing a server, or a whole zone, costs seconds of errors instead of an outage, and heals itself with nobody watching.
You’ll build it by hand on top of Project 1’s network, then break it on purpose and measure what customers would have seen: how many requests failed, and how long recovery took. “It seemed fine” doesn’t count. Numbers do.
What you’ll have at the end
Choose a failure to see what the environment does about it. Then switch the database between Single-AZ and Multi-AZ and lose the zone again.
| Piece | Name | What it’s for |
|---|---|---|
| Everything from Project 1 | quickcart-vpc and the rest | The network, endpoints, security groups and roles |
| Secret | quickcart/db | The database login, never in code or in the launch template |
| Database | quickcart-db, MySQL on RDS | Every order, in the data subnets |
| App files | in quickcart-assets-111122223333, under app/ | The app, its service file and its log settings. New servers copy them at boot |
| Certificate | in Certificate Manager | HTTPS. Two paths: your own domain, or self-signed |
| Launch template | quickcart-app-lt | The recipe every app server is built from |
| Target group | quickcart-tg | Which servers get traffic, and the health check that decides |
| Load balancer | quickcart-alb | One HTTPS address in front of every server, in both zones |
| Auto scaling group | quickcart-asg | Keeps 4 servers running across two zones, and replaces any that fail |
| Logs, alarms and email | /quickcart/app, 4 alarms, quickcart-alerts | Knowing about a problem before a customer tells you |
Before you start
You need Project 1’s network, built and passing its checks: the VPC, subnets, route tables, NAT gateway, endpoints, the four security groups, the quickcart-assets-111122223333 bucket and quickcart-app-role. If you cleaned it up, rebuild stages 1 to 9 of Project 1 first. The test server and flow logs are optional.
Read these first:
| Lesson | What you need from it |
|---|---|
| Servers on EC2 | Images, instance types and user data |
| Load balancers | Listeners, target groups and health checks |
| Auto scaling | Desired, minimum and maximum, and replacing failed servers |
| Managed databases: RDS | Subnet groups, Single-AZ and Multi-AZ, backups |
| Secrets | Why the password lives in Secrets Manager |
| Domain names and HTTPS | Certificates, and pointing a name at a load balancer |
| Logs and metrics and Alarms that actually fire | Log groups, metric filters and alarms |
You need:
- The console, signed in as in Project 1, with the region set to US East (N. Virginia) us-east-1.
- AWS CloudShell, the terminal built into the console: the icon at the top of any console page. It has the AWS CLI,
curlandopensslalready installed. You’ll use it to run the failure tests from outside the VPC, like a customer. - For the trusted-certificate path: a domain you control. Registering one through Route 53 → Registered domains costs a few dollars a year for the cheaper endings. Without one, take the self-signed path in stage 5.
Cost
Costs money — about $6.50 a day while everything runs, including Project 1’s network. Delete it when you’re done.
| Resource | Rough price in us-east-1 | For this project |
|---|---|---|
| Project 1’s network: the NAT gateway, its address and the interface endpoints | see Project 1 | about $3.20 a day |
| Application Load Balancer | about $0.0225 an hour, plus about $0.008 per capacity unit-hour | about $0.60 a day at test traffic |
| The load balancer’s public addresses | about $0.005 an hour each, one per zone | about $0.24 a day |
4 app servers, t3.micro, with 8 GB disks | about $0.0104 an hour each, plus a little for disks | about $1.10 a day |
RDS MySQL db.t4g.micro, Single-AZ, 20 GB | about $0.016 an hour, plus about $0.12 per GB-month of storage | about $0.50 a day. Multi-AZ roughly doubles it |
| Secrets Manager: 2 secrets | about $0.40 a month each | about 3 cents a day |
| CloudWatch: 4 alarms and a little log data | about $0.10 per alarm a month; about $0.50 per GB of logs | pennies |
| SNS email notifications | the first 1,000 emails a month are free | $0 |
| Public certificate from Certificate Manager | free | $0 |
| Route 53, trusted path only | about $0.50 a month for the hosted zone, plus the domain’s yearly fee | about 2 cents a day, plus the domain |
Some of this may be covered by the AWS free tier on a new account. Prices change; check the AWS pricing pages.
Stage 1: Check the network from Project 1
Two minutes of checking now saves an hour of confusion later.
Check the four things Project 2 depends on
Where Check VPC → Subnets Six quickcart-subnets, three in each zone. app-a and app-b use their own route tables, whose0.0.0.0/0goes to the NAT gatewayVPC → NAT gateways quickcart-nat-ais AvailableVPC → Endpoints Five endpoints, all Available VPC → Security groups → quickcart-app-sg→ Outbound rulesAll traffic to 0.0.0.0/0. Without it, new servers can’t finish booting
Checkpoint
Every row above checks out. If anything is missing, go back to that stage of Project 1 before going on.
Stage 2: The database password
The password has to reach the app without ever appearing in the launch template, in git, or in a file someone might screenshot. It lives in Secrets Manager, and the app reads it at boot through the endpoint from Project 1.
Make a strong password
The Secrets Manager console has no password generator for this kind of secret, so use the CLI in CloudShell. Open CloudShell from the console’s top bar and run:
Terminalaws secretsmanager get-random-password --password-length 24 --exclude-punctuation --query RandomPassword --output textPart What it means aws secretsmanager get-random-passwordask Secrets Manager to generate a random password. It stores nothing --password-length 2424 characters long --exclude-punctuationletters and numbers only. RDS refuses some symbols in a master password, such as /,@and quotes--query RandomPassword --output textprint only the password, as plain text You should seeq7RkT2vWm9xLp4ZsN8cYh3JaYours is different. Copy it; you’ll paste it twice in the next few minutes, then never need it again.
Store it as a secret
In the AWS console: Secrets ManagerSecretsStore a new secret
Field Enter Secret type Other type of secret Key/value pairs Key username, valuequickcart_admin. Choose Add row: keypassword, value the password you just generatedEncryption key aws/secretsmanager Choose Next.
Field Enter Secret name quickcart/dbDescription Master login for quickcart-dbChoose Next, leave rotation off, choose Next, then Store.
The database’s address isn’t in the secret. An address isn’t secret, and it goes in the app’s settings instead.
Let the app role read it
In Project 1, the role could read only
quickcart/test. Add this secret, and nothing else.In the AWS console: IAMRolesquickcart-app-rolePermissionsquickcart-read-secretEdit
Replace the JSON with this, using your own account ID:
quickcart-read-secret{ "Version": "2012-10-17", "Statement": [ { "Sid": "ReadTheAppSecrets", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": [ "arn:aws:secretsmanager:us-east-1:111122223333:secret:quickcart/test-*", "arn:aws:secretsmanager:us-east-1:111122223333:secret:quickcart/db-*" ] } ] }Part What it means "Resource": [ ]a list, so one statement can cover two named secrets quickcart/db-*the new secret. The -*matches the six random characters AWS adds to its ARNChoose Next, then Save changes.
Checkpoint
quickcart/db in the Secrets list. On quickcart-app-role, the quickcart-read-secret policy lists two resources, both ending in -*.
Stage 3: The database
Create a subnet group for the data subnets
A subnet group tells RDS which subnets it may use. RDS insists on two zones, even for a Single-AZ database.
In the AWS console: Aurora and RDSSubnet groupsCreate DB subnet group
Field Enter Name quickcart-db-subnetsDescription QuickCart data subnetsVPC quickcart-vpcAvailability Zones us-east-1aandus-east-1bSubnets quickcart-data-a(10.0.21.0/24) andquickcart-data-b(10.0.22.0/24), and nothing elseChoose Create. The subnet list shows every subnet in the chosen zones. Tick a public one by mistake and RDS could place the database somewhere routable.
Create the database
In the AWS console: Aurora and RDSDatabasesCreate database
Field Enter Database creation method Standard create Engine type MySQL Engine version The default: the newest MySQL 8.4 version offered Templates Dev/Test. If your account only lets you choose Free tier, choose that Deployment options Single-AZ DB instance deployment (1 instance). See the note below DB instance identifier quickcart-dbMaster username quickcart_adminCredentials management Self managed Master password, Confirm master password The password from stage 2 DB instance class Burstable classes → db.t4g.microStorage type General Purpose SSD (gp3) Allocated storage 20GiBEnable storage autoscaling Unticked Compute resource Don’t connect to an EC2 compute resource Virtual private cloud (VPC) quickcart-vpcDB subnet group quickcart-db-subnetsPublic access No VPC security group (firewall) Choose existing → quickcart-db-sg. RemovedefaultAvailability Zone No preference Database authentication Password authentication Open Additional configuration near the bottom. It’s easy to miss, and one setting in it matters:
Field Enter Initial database name quickcart. Don’t skip this. Without it, RDS creates a server with no database inside, and the app fails later withUnknown databaseBackup retention period 1dayEnable encryption Ticked, with the default key Enable deletion protection Unticked, so you can clean up Leave everything else at its default, and choose Create database. It takes 10 to 20 minutes. Carry on with stages 4 and 5 while it builds.
Copy the database's address
When the status reads Available:
In the AWS console: Aurora and RDSDatabasesquickcart-dbConnectivity & security
You should seeAn Endpoint like
quickcart-db.abcdefghijkl.us-east-1.rds.amazonaws.com, on port3306. Copy it. This guide calls itDB_ENDPOINT.
Checkpoint
quickcart-db with Status: Available, Engine: MySQL Community, Size: db.t4g.micro, and on Connectivity & security: Publicly accessible: No, VPC security groups: quickcart-db-sg, and Subnet group: quickcart-db-subnets. On Configuration, DB name: quickcart.
Stage 4: The app files
Every new server needs the app. Instead of pasting the whole app into the launch template, you put five files in the S3 bucket. At boot, each server copies them through the free S3 endpoint, using the role’s read-only access to that one bucket.
On your computer, make a folder called quickcart-app and create these five files in it.
The settings
The four settings the app needs. None of them is a password. Put your own database address in place of
DB_ENDPOINT:settings.envAWS_REGION=us-east-1 SECRET_ID=quickcart/db DB_HOST=DB_ENDPOINT DB_NAME=quickcartSetting What it is AWS_REGIONThe region of the secret SECRET_IDThe name of the secret that holds the database login DB_HOSTThe database’s address, from stage 3 DB_NAMEThe database inside the server Keeping them in one file means the app and the seeding script below always agree.
The app
A small web app with three pages.
/counts the orders in the database./healthsays the server is alive, and doesn’t touch the database, on purpose./burnkeeps the CPU busy for five seconds, to test scaling. Every request writes one line of JSON to a log file.app.py"""QuickCart's orders app for Project 2.""" import json import logging import os import ssl import time import urllib.request import boto3 import pymysql from flask import Flask, jsonify REGION = os.environ["AWS_REGION"] SECRET_ID = os.environ["SECRET_ID"] DB_HOST = os.environ["DB_HOST"] DB_NAME = os.environ["DB_NAME"] METADATA = "http://169.254.169.254/latest/" def metadata(path): """Ask the instance metadata service (IMDSv2) about this server.""" token_request = urllib.request.Request( METADATA + "api/token", method="PUT", headers={"X-aws-ec2-metadata-token-ttl-seconds": "300"}, ) token = urllib.request.urlopen(token_request, timeout=2).read().decode() request = urllib.request.Request( METADATA + "meta-data/" + path, headers={"X-aws-ec2-metadata-token": token}, ) return urllib.request.urlopen(request, timeout=2).read().decode() INSTANCE_ID = metadata("instance-id") ZONE = metadata("placement/availability-zone") log = logging.getLogger("quickcart") handler = logging.FileHandler("/var/log/app/app.log") handler.setFormatter(logging.Formatter("%(message)s")) log.addHandler(handler) log.setLevel(logging.INFO) def write_log(level="INFO", **fields): """Write one line of JSON, so CloudWatch can filter on any field.""" fields["level"] = level fields["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) fields["instance_id"] = INSTANCE_ID fields["zone"] = ZONE log.info(json.dumps(fields)) secrets = boto3.client("secretsmanager", region_name=REGION) db_login = None TLS = ssl.create_default_context() TLS.check_hostname = False TLS.verify_mode = ssl.CERT_NONE def login(): """Read the database login from Secrets Manager once, then reuse it.""" global db_login if db_login is None: value = secrets.get_secret_value(SecretId=SECRET_ID)["SecretString"] db_login = json.loads(value) write_log(event="secret_loaded") return db_login def connect(): """A fresh, encrypted connection per request, giving up after 3 seconds.""" user = login() return pymysql.connect( host=DB_HOST, user=user["username"], password=user["password"], database=DB_NAME, ssl=TLS, connect_timeout=3, read_timeout=3, ) app = Flask(__name__) @app.get("/health") def health(): return jsonify(status="ok", instance_id=INSTANCE_ID, zone=ZONE) @app.get("/") def orders(): started = time.time() try: with connect() as conn: with conn.cursor() as cursor: cursor.execute("SELECT COUNT(*) FROM orders") (count,) = cursor.fetchone() took = int((time.time() - started) * 1000) write_log(route="/", status=200, latency_ms=took, orders=count) return jsonify(instance_id=INSTANCE_ID, zone=ZONE, orders=count) except Exception as error: took = int((time.time() - started) * 1000) write_log( level="ERROR", route="/", status=500, latency_ms=took, error_type=type(error).__name__, message=str(error)[:300], ) return jsonify(error="database_unavailable", instance_id=INSTANCE_ID, zone=ZONE), 500 @app.get("/burn") def burn(): started = time.time() while time.time() - started < 5: pow(3, 200000) write_log(route="/burn", status=200, latency_ms=5000) return jsonify(burned=True, instance_id=INSTANCE_ID, zone=ZONE)Part What it does os.environ[...]Reads the four settings, which the service file loads from settings.envmetadata()Asks the server’s metadata service for its own instance ID and zone, using the token that IMDSv2 requires. Every response and log line includes them, so you can see which server answered write_log()Writes each event as one line of JSON. CloudWatch can then filter on level,error_typeor any other fieldTLSEncrypt the connection to the database. It doesn’t check the database’s certificate, because that needs AWS’s RDS certificate bundle on the server. Inside a private subnet, behind the security group chain, that’s an acceptable trade for a project. In production, download the bundle and verify it: Managed databases: RDS shows how login()Reads the database login from Secrets Manager the first time it’s needed, then keeps it in memory connect()A fresh database connection per request, which gives up after 3 seconds instead of hanging /healthSays “ok” without touching the database. Shallow on purpose: see below /Counts the orders. If anything fails, it logs the error type and message, and returns a 500 /burnFive seconds of pure CPU work, to push the CPU up Why
/healthdoesn’t check the database. A health check that queries the database sounds more thorough. But if the database has a problem, every server fails its check at the same moment. The load balancer then removes all of them, and a database problem becomes a total outage, with the auto scaling group replacing perfectly healthy servers in a loop. The health check answers one question: is this server able to serve? Whether the database is reachable is a job for an alarm and a human.Why encrypt at all? MySQL 8.4, the version RDS offers by default, signs users in with a method that needs either an encrypted connection or an extra library to send the password safely. Encrypting is the simpler and safer of the two.
The seeding script
The app expects an
orderstable. This script creates the database and tables if they’re missing, and adds a few sample orders. Running it twice does no harm.seed.py"""Create QuickCart's database, tables and a few sample orders. Safe to run twice.""" import json import ssl import boto3 import pymysql settings = {} with open("/opt/app/settings.env") as file: for line in file: line = line.strip() if line and not line.startswith("#"): key, value = line.split("=", 1) settings[key] = value secrets = boto3.client("secretsmanager", region_name=settings["AWS_REGION"]) secret = secrets.get_secret_value(SecretId=settings["SECRET_ID"]) login = json.loads(secret["SecretString"]) tls = ssl.create_default_context() tls.check_hostname = False tls.verify_mode = ssl.CERT_NONE conn = pymysql.connect( host=settings["DB_HOST"], user=login["username"], password=login["password"], ssl=tls, connect_timeout=5, autocommit=True, ) statements = [ "CREATE DATABASE IF NOT EXISTS " + settings["DB_NAME"], "USE " + settings["DB_NAME"], """CREATE TABLE IF NOT EXISTS customers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )""", """CREATE TABLE IF NOT EXISTS orders ( id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, amount DECIMAL(10, 2) NOT NULL, status ENUM('pending', 'paid', 'refunded', 'cancelled') NOT NULL DEFAULT 'pending', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) )""", """INSERT IGNORE INTO customers (id, name, email) VALUES (1, 'Ana', 'ana@example.com'), (2, 'Kofi', 'kofi@example.com'), (3, 'Noor', 'noor@example.com')""", """INSERT IGNORE INTO orders (id, customer_id, amount, status) VALUES (1, 1, 24.50, 'paid'), (2, 1, 12.00, 'paid'), (3, 2, 31.75, 'pending'), (4, 2, 8.20, 'refunded'), (5, 3, 45.00, 'paid')""", ] with conn: with conn.cursor() as cursor: for statement in statements: cursor.execute(statement) cursor.execute("SELECT COUNT(*) FROM orders") (count,) = cursor.fetchone() print("orders in the database:", count)Part What it does Reading settings.envUses the same four settings as the app, so there’s one place to change them get_secret_valueReads the login from Secrets Manager, so you never type the password CREATE DATABASE IF NOT EXISTSCreates the quickcartdatabase if RDS didn’tCREATE TABLE IF NOT EXISTSTwo tables: customers, and their orders. Each order must belong to a real customer ( FOREIGN KEY)INSERT IGNOREwith fixedidsAdds the sample rows, and silently skips any that already exist. That’s what makes running it twice safe print(…)Shows how many orders there are at the end The service file
This tells Linux how to run the app, and restarts it if it crashes:
quickcart-app.service[Unit] Description=QuickCart orders app Wants=network-online.target After=network-online.target [Service] EnvironmentFile=/opt/app/settings.env WorkingDirectory=/opt/app ExecStart=/usr/local/bin/gunicorn --workers 2 --bind 0.0.0.0:80 app:app Restart=always [Install] WantedBy=multi-user.targetLine What it does After=network-online.targetStart only once the network is up EnvironmentFile=/opt/app/settings.envLoad the four settings as environment variables for the app WorkingDirectory=/opt/appRun from the folder the app is copied into ExecStart=… gunicorn --workers 2 --bind 0.0.0.0:80 app:appRun the app with gunicorn, a production web server: 2 worker processes, on port 80, which is the port the target group checks. app:appmeans “theappobject inapp.py”Restart=alwaysIf the app crashes, start it again WantedBy=multi-user.targetStart it at every boot The log settings
This tells the CloudWatch agent which log files to send to CloudWatch:
cloudwatch-agent.json{ "logs": { "logs_collected": { "files": { "collect_list": [ { "file_path": "/var/log/app/app.log", "log_group_name": "/quickcart/app", "log_stream_name": "{instance_id}" }, { "file_path": "/var/log/cloud-init-output.log", "log_group_name": "/quickcart/bootstrap", "log_stream_name": "{instance_id}" } ] } } } }Setting What it does file_pathWhich file on the server to follow log_group_nameWhere it goes in CloudWatch. The agent creates the group if it doesn’t exist log_stream_name{instance_id}One stream per server, named after it, so you can see which server wrote what The second file, cloud-init-output.logEverything the boot script printed. When a server fails to boot, the reason is in here Upload the five files
In the AWS console: S3quickcart-assets-111122223333Create folder
Name the folder
appand choose Create folder. Open it, choose Upload, add all five files, and choose Upload.
Checkpoint
The bucket’s app/ folder holds exactly five files: app.py, cloudwatch-agent.json, quickcart-app.service, seed.py and settings.env. Open settings.env, choose Open, and check that the DB_HOST line has your real database address, not DB_ENDPOINT.
Stage 5: The certificate
The load balancer won’t accept an HTTPS listener without a certificate. There are two ways to get one. Choose one path and follow only that one.
| Path A: a domain you own | Path B: self-signed | |
|---|---|---|
| What you need | A domain, with its DNS in Route 53 | Nothing |
| Cost | Free certificate; the domain costs a few dollars a year | Free |
| Browsers | Trusted: a padlock, no warning | A full-page warning. Test tools must skip checking with curl -k |
| Renewal | Automatic, as long as the validation record stays in DNS | None: it expires after the 30 days you give it |
| Fit for real customers | Yes | Never. It’s encrypted, but nothing proves who’s on the other end |
Path B teaches everything this project is about. What it can’t show you is the trust part of HTTPS, which Domain names and HTTPS covers.
Path A: request a public certificate
In the AWS console: Certificate ManagerRequest certificate
Field Enter Certificate type Request a public certificate Fully qualified domain name shop.example.com, using your own domainAllow export Disable export. Exportable certificates cost extra, and the load balancer doesn’t need one Validation method DNS validation Key algorithm RSA 2048 Choose Request. Open the certificate, choose Create records in Route 53, and then Create records.
Why DNS validation: Certificate Manager renews the certificate automatically for as long as that record exists. Email validation needs a person to click a link at each renewal.
Path B: make a self-signed certificate
In CloudShell:
Terminalopenssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout quickcart.key -out quickcart.crt -subj "/CN=shop.example.com" -addext "subjectAltName=DNS:shop.example.com"Part What it means openssl req -x509make a certificate that signs itself, instead of a request for someone else to sign -newkey rsa:2048make a new 2048-bit RSA private key to go with it -nodesdon’t protect the key with a passphrase: the load balancer has no way to type one -days 30valid for 30 days -keyout quickcart.key,-out quickcart.crtthe files to write: the private key and the certificate -subj "/CN=shop.example.com"the name the certificate is for -addext "subjectAltName=DNS:shop.example.com"the same name in the field that browsers and tools actually check It prints a few lines of dots and plus signs as it generates the key. Now print each file, to copy it:
Terminalcat quickcart.crtPart What it means catprint a file’s contents quickcart.crtthe certificate You should see-----BEGIN CERTIFICATE----- MIIDKzCCAhOgAwIBAgIUX1c3m4p8Yq2vR0bT9k6Hn5d0aWUwDQYJKoZIhvcNAQEL -----END CERTIFICATE-----Yours has many more lines between the first and last. Then:
In the AWS console: Certificate ManagerImport certificate
Field Enter Certificate body Everything cat quickcart.crtprinted, including theBEGINandENDlinesCertificate private key Everything cat quickcart.keyprints, the same wayCertificate chain Leave empty: a self-signed certificate has no chain Choose Next, then Import. The private key is now stored in Certificate Manager. Delete the local copy with
rm quickcart.keyonce the import succeeds.
Checkpoint
In Certificate Manager, the certificate for shop.example.com (with your own name on path A) shows Status: Issued. On path A that can take a few minutes after the DNS record is created. On path B it’s Issued straight away, and its Type says Imported.
Stage 6: The launch template
The launch template is the recipe every app server is built from. Get it right, and the auto scaling group needs no more attention.
Create the template
In the AWS console: EC2Launch templatesCreate launch template
Field Enter Launch template name quickcart-app-ltTemplate version description v1: app from S3Auto Scaling guidance Ticked Application and OS Images Quick Start → Amazon Linux → Amazon Linux 2023 AMI, 64-bit (x86) Instance type t3.microKey pair name Don’t include in launch template. You’ll use Session Manager Subnet Don’t include in launch template. The auto scaling group chooses Firewall (security groups) Select existing security group → quickcart-app-sgOpen Advanced details:
Field Enter IAM instance profile quickcart-app-roleMetadata accessible Enabled Metadata version V2 only (token required). This blocks a whole class of attack in which a vulnerable web app is tricked into fetching the server’s credentials User data The script below The boot script
This runs once, as root, the first time each server starts. Put your own bucket name in place of the highlighted account number:
User data#!/bin/bash set -euxo pipefail dnf install -y python3-pip amazon-cloudwatch-agent pip3 install flask pymysql boto3 gunicorn mkdir -p /opt/app /var/log/app aws s3 cp s3://quickcart-assets-111122223333/app/ /opt/app/ --recursive --region us-east-1 cp /opt/app/quickcart-app.service /etc/systemd/system/quickcart-app.service /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c file:/opt/app/cloudwatch-agent.json systemctl daemon-reload systemctl enable --now quickcart-appLine What it does #!/bin/bashRun this with bash set -euxo pipefailStop at the first error, and print each command before running it. Both help when you read the boot log dnf install -y …Install pip and the CloudWatch agent. The packages come through the NAT gateway pip3 install …Install the app’s Python libraries: Flask, the MySQL driver, the AWS SDK and gunicorn mkdir -p /opt/app /var/log/appMake the app’s folder and its log folder aws s3 cp … --recursiveCopy the five files from the bucket, through the S3 gateway endpoint. The role’s credentials are used automatically cp … /etc/systemd/system/Put the service file where Linux looks for services. It loads settings.envfrom/opt/appamazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c file:…Load the log settings ( fetch-config) for an EC2 server (-m ec2), and start the agent (-s)systemctl daemon-reloadMake Linux read the new service file systemctl enable --now quickcart-appStart the app now, and at every boot Choose Create launch template.
Checkpoint
quickcart-app-lt in Launch templates, Latest version: 1. Open it, then the Details of version 1: Security groups shows quickcart-app-sg, IAM instance profile shows quickcart-app-role, and Advanced details shows Metadata version: V2 only (token required).
Stage 7: The target group
A target group isn’t a network resource. It has no address. It answers two questions: which servers get traffic, and are they healthy right now? Every setting below is a choice you should be able to explain.
Create it
In the AWS console: EC2Target groupsCreate target group
Field Enter Choose a target type Instances Target group name quickcart-tgProtocol : Port HTTP : 80IP address type IPv4 VPC quickcart-vpc. A target group can never move to another VPC, so check this oneProtocol version HTTP1 Health check protocol HTTP Health check path /healthOpen Advanced health check settings:
Setting Enter Why Health check port Traffic port Check the same port that real requests use Healthy threshold 2Two passes in a row before a server gets traffic Unhealthy threshold 2Two failures in a row before it’s removed. One would react to a single blip Timeout 5secondsA slower answer counts as a failure. Must be less than the interval Interval 10secondsHow often each server is checked Success codes 200Only a real 200 counts; a redirect or an error fails Choose Next. Register no targets: the auto scaling group does that. Choose Create target group.
The number to remember: a dead server is noticed after interval × unhealthy threshold = 10 × 2 = about 20 seconds, plus up to one more interval. Requests sent to it in that window fail. That’s the ceiling you’ll measure against in Prove it.
Shorten the drain time
In the AWS console: EC2Target groupsquickcart-tgAttributesEdit
Field Enter Deregistration delay 30secondsChoose Save changes.
When a server is removed on purpose, the load balancer stops sending it new requests but waits this long for requests already in progress to finish. The default is 300 seconds, which makes every test and every scale-in drag. 30 is plenty for requests that take milliseconds.
Checkpoint
quickcart-tg with Target type: Instance, Protocol : Port: HTTP: 80, VPC: quickcart-vpc, and on Health checks: path /health, healthy 2, unhealthy 2, timeout 5, interval 10, success codes 200. Attributes shows a deregistration delay of 30 seconds. There are no targets yet.
Stage 8: The load balancer
Create the load balancer
In the AWS console: EC2Load balancersCreate load balancerApplication Load BalancerCreate
Field Enter Load balancer name quickcart-albScheme Internet-facing Load balancer IP address type IPv4 VPC quickcart-vpcAvailability Zones and subnets Tick us-east-1a → quickcart-public-aand us-east-1b →quickcart-public-bSecurity groups quickcart-alb-sg. RemovedefaultUnder Listeners and routing, change the first listener:
Field Enter Protocol HTTPS Port 443Default action Forward to target groups → quickcart-tgSecurity policy Leave the recommended default Default SSL/TLS server certificate From ACM → the certificate from stage 5 Choose Create load balancer. It takes a few minutes to become Active.
Redirect plain HTTP to HTTPS
In the AWS console: EC2Load balancersquickcart-albListeners and rulesAdd listener
Field Enter Protocol : Port HTTP : 80Default action Redirect to URL Redirect URI parts: Protocol HTTPS, Port 443Status code 301 - Permanently moved Choose Add. Someone who types the address without
https://now gets sent to HTTPS, instead of an error or, worse, an unencrypted page.Path A only: point your name at the load balancer
In the AWS console: Route 53Hosted zonesexample.comCreate record
Field Enter Record name shopRecord type A Alias On Route traffic to Alias to Application and Classic Load Balancer → US East (N. Virginia) → quickcart-albRouting policy Simple routing Evaluate target health Yes Choose Create records. An alias record follows the load balancer’s addresses as they change, which a record with a fixed address never would.
Checkpoint
quickcart-alb with State: Active, in two zones, with two listeners: HTTPS:443 forwarding to quickcart-tg, and HTTP:80 redirecting to HTTPS. Copy its DNS name, like quickcart-alb-1234567890.us-east-1.elb.amazonaws.com. On path B, that’s the address you’ll test against. The target group still has no targets, so for now the address returns 503.
Stage 9: The auto scaling group
This is the part that makes the environment heal itself. It keeps the right number of servers running, registers them with the target group, replaces any that fail, and keeps the two zones balanced.
Create the group
In the AWS console: EC2Auto Scaling groupsCreate Auto Scaling group
Step 1: Choose launch template
Field Enter Auto Scaling group name quickcart-asgLaunch template quickcart-app-lt, version LatestStep 2: Choose instance launch options
Field Enter VPC quickcart-vpcAvailability Zones and subnets quickcart-app-aandquickcart-app-b. Only the app subnetsAvailability Zone distribution Balanced best effort Step 3: Integrate with other services
Field Enter Load balancing Attach to an existing load balancer → Choose from your load balancer target groups → quickcart-tgTurn on Elastic Load Balancing health checks Ticked Health check grace period 300secondsStep 4: Configure group size and scaling
Field Enter Desired capacity 4Min desired capacity 2Max desired capacity 8Automatic scaling Target tracking scaling policy Metric type Average CPU utilization Target value 50Instance warmup 300secondsStep 5 (notifications): skip. Step 6: Add tags: key
Name, valuequickcart-app. Then Create Auto Scaling group.Choice Why ELB health checks on The default EC2 check only asks “is the machine running?”. If the app crashes but Linux is fine, that check passes forever. The ELB check uses /health, so a server that can’t serve is replacedGrace period 300 seconds Boot, packages and the app take a minute or two. Health checks start counting only after the grace period. Set it too short and servers get killed halfway through booting, replaced, and killed again, forever Desired 4, not 2 With 2, losing a zone leaves 1 server carrying everything. With 4, each zone alone has 2 servers that can carry the whole load Minimum 2 Scaling in never drops below one server per zone CPU target 50% Add servers when the average CPU stays above half, long before they’re overloaded
Checkpoint
After about five minutes, EC2 → Target groups → quickcart-tg → Targets lists 4 healthy targets, two in us-east-1a and two in us-east-1b. If they cycle between initial and unhealthy, go to Troubleshooting before going on.
Now test from CloudShell. On path A, use your own name. On path B, use the load balancer’s DNS name. On path A you can leave out -k.
curl -sk https://shop.example.com/health| Part | What it means |
|---|---|
curl -s | fetch a page, quietly |
-k | don’t check the certificate. Needed on path B only, where the certificate is self-signed |
https://shop.example.com/health | the health page, through the load balancer. Use your own name, or the load balancer’s DNS name |
{"instance_id":"i-0a1b2c3d","status":"ok","zone":"us-east-1b"}Run it a few more times: the instance ID and zone change as the load balancer spreads the requests. Now try the main page:
curl -sk https://shop.example.com/| Part | What it means |
|---|---|
curl -sk | fetch quietly, without checking the certificate |
https://shop.example.com/ | the main page, which counts the orders |
{"error":"database_unavailable","instance_id":"i-0a1b2c3d","zone":"us-east-1a"}That error is expected. The database has no orders table yet. The next stage creates it.
Stage 10: Seed the database
Run the seeding script on one app server
In the AWS console: EC2Instancesany quickcart-app instanceConnectSession ManagerConnect
In the Session Manager terminal:
Terminalpython3 /opt/app/seed.pyPart What it means python3run a Python program /opt/app/seed.pythe seeding script, which the boot script copied from the bucket You should seeorders in the database: 5That one command read the login from Secrets Manager through its endpoint, using the server’s role, and reached the database through the security group chain. You never saw or typed the password. Close the Session Manager tab.
Checkpoint
From CloudShell:
curl -sk https://shop.example.com/| Part | What it means |
|---|---|
curl -sk https://shop.example.com/ | fetch the main page again. Use your own address |
{"instance_id":"i-0a1b2c3d","orders":5,"zone":"us-east-1a"}Five orders, from whichever server answered. Every piece you’ve built just took part in that one request: the load balancer, a server built from the template, the role, the secret through its endpoint, and the database through the security group chain.
Stage 11: Alarms that fire
An alarm you’ve never seen go off is a guess. You’ll build four alarms that email you, then set one off on purpose.
An email topic, confirmed
In the AWS console: Simple Notification ServiceTopicsCreate topic
Field Enter Type Standard Name quickcart-alertsChoose Create topic, then Create subscription:
Field Enter Protocol Email Endpoint Your email address Choose Create subscription. Open the email from AWS Notifications and choose Confirm subscription. Until you do, the subscription is Pending confirmation and every alarm email is silently dropped.
Turn app errors into a number
The app logs each failure as a JSON line with
"level": "ERROR". A metric filter counts those lines, and an alarm can watch the count.In the AWS console: CloudWatchLog groups/quickcart/appActionsCreate metric filter
Field Enter Filter pattern { $.level = "ERROR" }Choose Next.
Field Enter Filter name quickcart-app-errorsMetric namespace QuickCart/AppMetric name AppErrorCountMetric value 1Default value 0Unit Count Choose Next, then Create metric filter. The
$.levelpattern only works because the app logs JSON. With plain text lines, you’d be matching words and hoping.If
/quickcart/appisn’t in the list yet, the agent hasn’t sent anything. Load the main page a few times from CloudShell, and it appears.Four alarms
For each row, go to CloudWatch → Alarms → Create alarm → Select metric, find the metric, and fill in the rest. Every alarm sends to
quickcart-alertswhen it goes In alarm.Alarm name Metric, and where to find it Settings quickcart-unhealthy-hostsUnHealthyHostCountforquickcart-tg, under ApplicationELB → Per AppELB, per TG MetricsMaximum over 1 minute. Greater/Equal 1, for 2 out of 2 datapoints. Treat missing data as missingquickcart-alb-5xxHTTPCode_ELB_5XX_Countforquickcart-alb, under ApplicationELB → Per AppELB MetricsSum over 1 minute. Greater than 5, for 1 out of 1. Treat missing data as not breachingquickcart-app-errorsAppErrorCount, under QuickCart/App → Metrics with no dimensionsSum over 1 minute. Greater than 5, for 1 out of 1. Treat missing data as not breachingquickcart-db-storageFreeStorageSpaceforquickcart-db, under RDS → Per-Database MetricsMinimum over 5 minutes. Lower than 2000000000(2 GB, in bytes), for 1 out of 1. Treat missing data as missingChoice Why Unhealthy hosts, 2 of 2 Any unhealthy server is worth knowing about. Two minutes in a row avoids an alarm for every normal replacement ELB 5xx over 5 These come from the load balancer itself, usually meaning no healthy server was available. Normally zero App errors over 5 The app’s own view of failures, including ones the load balancer can’t see, like the database being down Free storage under 2 GB A full disk takes a database down hard, and recovery is slow not breaching for counts No data means no errors, so the alarm should stay OK missing for the others Without this choice, some alarms sit in Insufficient data and never tell you anything Set one off on purpose
Cut the app off from the database by removing the rule that lets it in:
In the AWS console: VPCSecurity groupsquickcart-db-sgInbound rulesEdit inbound rules
Choose Delete on the MySQL rule, and Save rules. Write down the time. In CloudShell, load the main page a few times:
Terminalcurl -sk https://shop.example.com/Part What it means curl -sk https://shop.example.com/the main page, which needs the database. Use your own address You should see{"error":"database_unavailable","instance_id":"i-0a1b2c3d","zone":"us-east-1b"}It takes about three seconds to fail: the connection timeout. Now the health page:
Terminalcurl -sk https://shop.example.com/healthPart What it means curl -sk https://shop.example.com/healththe health page, which doesn’t touch the database You should see{"instance_id":"i-0a1b2c3d","status":"ok","zone":"us-east-1a"}Degraded, not down. The servers stay in the load balancer, because they’re fine. Only the database is unreachable. With a health check that queried the database, all four servers would now be out of rotation, and the auto scaling group would be replacing them in a loop.
Keep loading the main page for a minute or two, so there are more than 5 errors in a minute. Then put the rule back: Add rule, type MYSQL/Aurora, source
quickcart-app-sg, Save rules.
Checkpoint
Within a few minutes, CloudWatch → Alarms shows quickcart-app-errors In alarm, and an email titled like ALARM: "quickcart-app-errors" in US East (N. Virginia) arrives. A few minutes after you put the rule back, the alarm returns to OK. The subscription on quickcart-alerts shows Confirmed.
Prove it
Now break it, on purpose, and measure. The measuring runs in CloudShell, which is outside your VPC, like a customer.
The measuring tools
In CloudShell, create two small scripts. Open the editor with nano probe.sh, paste, then save with Ctrl+O, Enter, and leave with Ctrl+X.
#!/bin/bash
# Usage: ./probe.sh URL RESULTS_FILE
# Every 0.2 seconds, ask URL once and add "time,status" to RESULTS_FILE.
# Stop it with Ctrl+C.
url="$1"
results="$2"
while true; do
printf '%(%H:%M:%S)T,' -1 >> "$results"
curl -sk -o /dev/null -w '%{http_code}\n' --max-time 3 "$url" >> "$results"
sleep 0.2
done| Line | What it does |
|---|---|
url="$1", results="$2" | The two things you give it: the address to test, and the file to write to |
while true; do … done | Repeat until you press Ctrl+C |
printf '%(%H:%M:%S)T,' -1 | Write the current time, like 14:05:07,. -1 means “now” |
curl -sk -o /dev/null | Make one request quietly, skip the certificate check (for path B), and throw away the page itself |
-w '%{http_code}\n' | Write only the status code: 200, 502, and so on. A request that got no answer at all is written as 000 |
--max-time 3 | Give up after 3 seconds. A customer’s app wouldn’t wait much longer |
>> "$results" | Add to the end of the file |
sleep 0.2 | Wait a fifth of a second, so about 4 or 5 requests a second |
Then nano summary.sh:
#!/bin/bash
# Usage: ./summary.sh RESULTS_FILE
# Counts the requests and the failures, and shows when the failures started and stopped.
awk -F, '
{ total++ }
$2 != "200" { failed++; if (first == "") first = $1; last = $1 }
END {
printf "requests: %d\n", total
printf "failed: %d\n", failed
if (failed > 0) printf "first failure: %s\nlast failure: %s\n", first, last
}
' "$1"| Line | What it does |
|---|---|
awk -F, | Read the file line by line, splitting each line at the comma: $1 is the time, $2 the status |
{ total++ } | Count every line |
$2 != "200" { … } | For any line that isn’t a 200: count it as failed, and remember the first and last time that happened |
END { printf … } | After the last line, print the totals |
"$1" | The results file you give it |
Make both runnable:
chmod +x probe.sh summary.sh| Part | What it means |
|---|---|
chmod +x | mark a file as a program you can run |
probe.sh summary.sh | both scripts |
It prints nothing when it works.
How each test runs
Every test follows the same pattern:
- Start the probe with a new results file, and leave it running for the whole test.
- Wait about 30 seconds, so there’s a clean baseline.
- Break something, and write down the exact time.
- Watch EC2 → Target groups → quickcart-tg → Targets, and write down when it’s back to 4 healthy.
- Stop the probe with Ctrl+C, and run the summary.
For example, for the first test:
./probe.sh https://shop.example.com/ test1.csv| Part | What it means |
|---|---|
./probe.sh | run the probe script from this folder |
https://shop.example.com/ | the main page. Use your own address. It needs a server and the database, so it catches both kinds of failure |
test1.csv | where to write the results. Use a new name for each test |
It prints nothing while it runs. When the test is over, press Ctrl+C, then:
./summary.sh test1.csv| Part | What it means |
|---|---|
./summary.sh | run the summary script |
test1.csv | the results to summarise |
requests: 1250
failed: 31
first failure: 14:05:09
last failure: 14:05:31Test 1: a server dies suddenly
With the probe running, terminate one app server directly, as if its hardware failed:
In the AWS console: EC2Instancesone quickcart-app instanceInstance stateTerminate (delete) instance
What to expect. The load balancer keeps sending that server about a quarter of the requests until two health checks fail: roughly 20 to 30 seconds. Some of those requests fail. Then the failures stop completely, and the auto scaling group launches a replacement, which is healthy again 3 to 5 minutes later.
Test 2: a server is removed gracefully
With a new probe running (
test2.csv), scale in by one, which removes a server the planned way:In the AWS console: EC2Auto Scaling groupsquickcart-asgDetailsGroup detailsEdit
Set Desired capacity to
3and choose Update. The group picks a server, deregisters it from the target group first, waits the 30-second deregistration delay while in-flight requests finish, and only then terminates it. When it’s gone, set Desired capacity back to4.What to expect: zero failed requests.
This pair is the centre of your results. Same outcome, a server gone, but one path respects deregistration and the other doesn’t. The difference in failed requests is the difference between an outage and a routine event. It’s also why releases, which replace servers on purpose, can be done with zero errors.
Test 3: a whole zone goes dark
You can’t switch off a real zone, but you can cut one off at the subnet, which is how AWS’s own fault-injection tooling does it. A network ACL is a stateless firewall on a subnet. A new one denies everything until you add rules, which makes it a perfect blackhole.
In the AWS console: VPCNetwork ACLsCreate network ACL
Field Enter Name quickcart-blackholeVPC quickcart-vpcChoose Create network ACL. Add no rules. Start a new probe (
test3.csv), wait 30 seconds, and then:In the AWS console: VPCNetwork ACLsquickcart-blackholeActionsEdit subnet associations
Tick
quickcart-app-aand choose Save changes. Write down the time. Zone a’s app servers are now cut off from everything.What to expect. For about 20 to 30 seconds, requests sent to zone a’s servers time out: roughly half of all requests in that window. Then the load balancer removes them, and zone b’s two servers carry everything, with no more failures. The auto scaling group launches replacements, but any it puts in
quickcart-app-acan’t pass their health checks while the blackhole is there, so it may keep replacing them. Zone b holds the site up throughout.Leave it for five minutes, then restore it: open the VPC’s default network ACL, choose Actions → Edit subnet associations, tick
quickcart-app-aand save. That moves the subnet back. Then deletequickcart-blackhole.Test 4: the database restarts
With a new probe running (
test4.csv):In the AWS console: Aurora and RDSDatabasesquickcart-dbActionsReboot
If you built Multi-AZ, tick Reboot with failover, which makes RDS switch to the standby in the other zone: a real failover test. On Single-AZ that option doesn’t exist, and a plain reboot tests something narrower but still important: whether the app reconnects by itself when the database comes back.
What to expect: the main page fails for somewhere around one to two minutes, then recovers with nobody touching anything, because the app opens a fresh connection for every request.
/healthstays 200 throughout, which is exactly why it doesn’t check the database.
Your results
Fill in this table from your summaries and your notes. These are your numbers; the expected ranges are only a guide.
| Test | Failed | Requests | Failures from, to | Back to 4 healthy after | Alarms that fired | Expected |
|---|---|---|---|---|---|---|
| 1. Server terminated suddenly | Some failures, for about 20 to 30 s; full strength in 3 to 5 min | |||||
| 2. Server removed gracefully | 0 failures | |||||
| 3. Zone a’s app subnet cut off | Some failures, for about 20 to 30 s; then 0 | |||||
| 4. Database rebooted | — | Failures for about 1 to 2 min, then 0, with no manual step |
Then write two sentences for each: what happened, and the mechanism. For example: “Failures stopped 22 seconds after the termination, which matches the health check’s 10-second interval × 2 failures.”
Troubleshooting
Every one of these happened in a real build of this environment.
Clean up
This removes everything from this project and Project 1, in dependency order. If you’re going straight on to a later project that reuses the network, you can stop after step 6, but the network keeps costing about $3.20 a day.
Stop the auto scaling group first
In the AWS console: EC2Auto Scaling groupsquickcart-asgActionsDelete
Type
deleteand confirm. Deleting the group terminates its servers. If you terminate servers yourself first, the group just launches new ones.Delete the load balancer, then the target group
In the AWS console: EC2Load balancersquickcart-albActionsDelete load balancer
Type
confirmand delete. Then:In the AWS console: EC2Target groupsquickcart-tgActionsDelete
On path A, also delete the
shoprecord: Route 53 → Hosted zones → your domain → the A record → Delete record. Keep the hosted zone if you’re keeping the domain.Delete the database and its subnet group
In the AWS console: Aurora and RDSDatabasesquickcart-dbActionsDelete
Untick Create final snapshot and Retain automated backups, tick the acknowledgement, type
delete me, and choose Delete. It takes several minutes. When it’s gone:In the AWS console: Aurora and RDSSubnet groupsquickcart-db-subnetsDelete
Delete the launch template and the certificate
In the AWS console: EC2Launch templatesquickcart-app-ltActionsDelete template
In the AWS console: Certificate Managerthe certificateDelete
The certificate can only be deleted once the load balancer that used it is gone.
Delete the alarms, the topic and the log groups
In the AWS console: CloudWatchAlarms
Tick the four
quickcart-alarms, choose Actions → Delete. Then Simple Notification Service → Topics → quickcart-alerts → Delete, typingdelete me. Then in CloudWatch → Log groups, tick/quickcart/app,/quickcart/bootstrapand, if it exists,/quickcart/vpc-flow-logs, and choose Actions → Delete log group(s).Schedule both secrets for deletion
In the AWS console: Secrets Managerquickcart/dbActionsDelete secret
Waiting period 7 days, then Schedule deletion. Do the same for
quickcart/testif it still exists.Take down Project 1's network
Follow Project 1’s clean-up from its second step: the endpoints, the NAT gateway and its Elastic IP, the flow log, the VPC, the bucket and the two IAM roles. Emptying the bucket also removes the
app/folder.Clean-up step: Confirm nothing is left billing
Where Should show EC2 → Instances No running quickcart-appinstancesEC2 → Load balancers None Aurora and RDS → Databases None VPC → NAT gateways None, or only Deleted ones VPC → Elastic IPs No addresses VPC → Endpoints None Resource Groups & Tag Editor → Tag Editor, region us-east-1, All supported resource types Nothing with a quickcartnameYou should seeEvery check comes back empty. Look at Billing and Cost Management → Bills the next day: the charges for these projects stop on the day you cleaned up.