Courses / Hands-on Cloud for Freshers
Session 8 of 8
Ship it, watch it, tear it down
Overview
Unit 8 — Ship It, Watch It, Tear It Down
Course: Hands-on Cloud for Freshers
Prerequisites: You have the notes API from Unit 7 — the Lambda function hoc-notes-api with a
public function URL (auth type NONE), its role hoc-notes-api-role, and the DynamoDB table
hoc-notes — and you can read its logs in CloudWatch. You can host a static website in an S3 bucket:
enable website hosting, allow public reads with a bucket policy, and upload an index.html. You know
what CORS is and why a browser enforces it. You have the AWS CLI version 2.32.0 or later, signed in
with aws login. If you are starting here, this unit’s project opens with the set-up steps (an
everyday sign-in, a budget alert, the CLI) and its appendix rebuilds the notes API.
What the reader can do after this unit:
- Put a static web page in front of a serverless API so that a visitor’s browser can list and add notes, with CORS allowing exactly one origin and the page safe against notes that contain HTML.
- Set up a CloudWatch alarm on the function’s
Errorsmetric that emails you, choose how it treats missing data, and prove it works both by forcing its state and by breaking the function for real. - Remove every resource a project created, in an order that leaves nothing half-deleted, prove each one gone with the listing of the service that held it — not from memory — and cross-check the billing pages afterwards.
Core question this unit answers: How do you know a small cloud application is working when you are not looking at it — and when you are finished with it, how do you prove that nothing is left running that could cost money?
Connections:
- Builds on: Unit 7’s notes API and its logs; Unit 3’s static website on S3; the tagging habit and the budget alert from earlier units.
- Leads into: describing the whole stack as code, so that it can be created and deleted with one command, and the other directions listed at the end of the student notes.
Your answers are scored
This unit is assessed. The quiz is marked against a key held separately from your materials. The first seven questions are multiple choice and are marked by comparing your letter. The last three ask you to write something — a command, an order of steps, a list of resources — and each is marked by a script that checks named properties of what you submit against a rubric kept in that key.
Two consequences worth knowing before you start:
- Each question states exactly what your answer must do. The rubric checks only those stated requirements; there is no opinion applied afterwards.
- Style is not marked. A different but equivalent order of options on a command line, or extra spaces in a list, scores exactly what the tidiest version does.
Notes
Hands-on Cloud for Freshers — Unit 8: Ship It, Watch It, Tear It Down
Before you start
Have these ready:
- The notes API from Unit 7:
hoc-notes-apiwith its function URL,hoc-notes-api-role, and thehoc-notestable. If you parked the function at the end of Unit 7, undo the parking you actually used — each kind is undone only by its own reverse. Parked with reserved concurrency0: remove it withaws lambda delete-function-concurrency --function-name hoc-notes-api; the function URLs guide says deleting the reserved concurrency configuration reactivates the URL, and changing the auth type does not. Parked by switching the auth type toAWS_IAM: set it back toNONE. The project’s Task 1 shows how to tell which applies. Check withcurl -s "$URL". - The AWS CLI version 2, at 2.32.0 or later (
aws --version), signed in withaws login; the CLI guide gives 2.32.0 as the minimum foraws login. - If you are starting here without the earlier units: the project begins with three set-up steps (an everyday sign-in that is not the root user, a budget alert, and the CLI) and ends with an appendix that rebuilds the notes API. Do those first; the rest of these notes assume them.
- An email address you can open now. The alarm in this unit sends to it, and AWS will not deliver anything until you click a confirmation link.
- A browser with developer tools (any current desktop browser: right-click a page → Inspect → Console and Network tabs).
Cost. Lambda, DynamoDB, CloudWatch and Amazon SNS each have an Always Free monthly allowance under the current Free Tier, and the CloudWatch pricing page lists 10 alarm metrics among its free allowances; this unit creates one alarm. An allowance is not a guarantee: the billing guide says usage beyond an allowance is covered by your Free Tier credits, and charged on a Paid plan once the credits are gone. S3 is priced per GB of storage per month, and the S3 pricing page says Free Tier credits can be applied to it; one small HTML file is a tiny fraction of a GB. How it is counted against your credits is shown on your own Free Tier page, so check it and your Credits page at the end as the project asks. Your budget alert stays on to the very end of the course — it is the last thing you would ever remove.
Every claim here about how these services and browsers behave is taken from the AWS and MDN pages listed under Sources, fetched on 2026-10-02.
Summary
A working application is not finished when it works; it is finished when you would know if it
stopped, and when you can remove it completely. This unit does all three. First you put a plain web
page, hosted on S3, in front of your notes API, which brings two new obligations: CORS (cross-origin resource sharing),
so the browser will let the page read the API’s answers, and output escaping, so a note that contains HTML
cannot run code in every visitor’s browser. Then you make the application watch itself: a CloudWatch
alarm on the function’s Errors metric, sending email through an SNS topic, configured so that a
quiet function reads as healthy rather than as unknown. Finally you tear down everything the course
created, in dependency order, using tags to find what you made and each service’s own listings to
prove it is gone — because a tag search can only find what you remembered to tag — with the billing
pages as a final cross-check that can reveal something your inventory missed.
Key concepts
| Term | Definition |
|---|---|
| Website endpoint | The address S3 serves a bucket’s static website from. For ap-south-1 it is http://<bucket>.s3-website.ap-south-1.amazonaws.com. It serves HTTP only |
| Origin | Scheme, host and port together. The page’s origin is the website endpoint, http:// included; the function URL is a different origin |
| CORS (cross-origin resource sharing) | A set of HTTP response headers by which a server tells the browser which other origins may read its responses. It is a browser restriction on requests a page’s script makes with fetch(): if the response does not allow the page’s origin in access-control-allow-origin, the script cannot read the answer and the details appear only in the browser’s console. A curl command in a terminal is not a page’s script, so it can succeed while the page fails |
| Output escaping | Inserting untrusted text into a page as text, never as HTML. In the browser, textContent does this; innerHTML does not |
| Metric | A time series CloudWatch keeps, such as Lambda’s Errors in the AWS/Lambda namespace, identified per function by the dimension FunctionName |
| Errors (Lambda metric) | The number of invocations that ended in a function error: an exception your code threw, or one the runtime threw, such as a timeout |
| Alarm | A watcher on one metric that compares a statistic over a period against a threshold and moves between the states OK, ALARM and INSUFFICIENT_DATA |
| Missing data treatment | What an alarm assumes for periods with no data points: missing (the default), notBreaching, breaching or ignore |
| SNS topic | A named channel that an alarm publishes to. Email subscribers receive what is published, once they have confirmed |
| Tag | A key–value label on a resource, such as project = hands-on-cloud. Tag Editor can search for resources by tag in chosen Regions |
| Teardown order | Deleting what uses something before the thing it uses — the alarm before its topic, the function before its role, the objects before their bucket |
Explanations
The failure, before the explanation
The course is over. You open Tag Editor, search your Region for project = hands-on-cloud
across all resource types, and it lists four resources: a Lambda function, a DynamoDB table, an S3
bucket and an SNS topic. You delete all four. Tag Editor now finds nothing. Done.
A week later the Lambda console’s Functions list is empty, but CloudWatch’s Log groups still
holds /aws/lambda/hoc-notes-api, the All alarms page shows hoc-notes-api-errors in
INSUFFICIENT_DATA, IAM still lists hoc-notes-api-role, and the EC2 console from Unit 4 shows a
security group you never tagged. Nothing here was hidden. Each one was simply never tagged: Lambda
created the log group itself, you created the alarm from a console page without adding a tag, and an
IAM role is not tied to any Region you searched. A tag search answers “what did I label?”, not “what
exists?” The rest of this unit builds the application and its alarm so that the final teardown
starts from a complete list rather than a lucky one.
Assembling the application
The finished application is five pieces, each from an earlier unit, plus the alarm:
browser ──HTTP──▶ S3 website (index.html)
│
└──HTTPS, fetch()──▶ function URL ──▶ Lambda hoc-notes-api ──▶ DynamoDB hoc-notes
│
├──▶ CloudWatch Logs /aws/lambda/hoc-notes-api
└──▶ CloudWatch metric Errors ──▶ alarm ──▶ SNS topic ──▶ your email
The page is fetched once from S3; after that the browser talks straight to the function URL. S3 never sees a note, and the function never serves HTML. That split is why this is cheap to run and simple to remove: two independent halves joined only by one URL in one file and one origin in one CORS setting.
The page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
</head>
<body>
<h1>Notes</h1>
<form id="add">
<input id="text" maxlength="500" placeholder="Write a note" required>
<button type="submit">Add</button>
</form>
<p id="message"></p>
<ul id="notes"></ul>
<script>
const FUNCTION_URL = "https://abc123example.lambda-url.ap-south-1.on.aws/";
const list = document.getElementById("notes");
const message = document.getElementById("message");
async function loadNotes() {
const response = await fetch(FUNCTION_URL);
const data = await response.json();
list.replaceChildren();
for (const note of data.notes) {
const item = document.createElement("li");
item.textContent = note.text ?? note.title; // Unit 6 notes have a title; never innerHTML: a note is untrusted input
list.appendChild(item);
}
}
document.getElementById("add").addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.getElementById("text");
const response = await fetch(FUNCTION_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: input.value }),
});
const data = await response.json();
message.textContent = response.ok ? "Saved." : `Not saved: ${data.error}`;
if (response.ok) {
input.value = "";
await loadNotes();
}
});
loadNotes().catch((error) => {
message.textContent = `Could not load notes: ${error}`;
});
</script>
</body>
</html>
Two lines deserve attention.
maxlength="500" is a convenience, not a defence. It stops a typist in a browser. It does
nothing to a curl request, which is why the function from Unit 7 checks the length again. Every
rule that matters is enforced on the server; the browser copy only saves a round trip.
textContent, not innerHTML. Show the failure first. Suppose the loop said
item.innerHTML = note.text, and someone posts this note with curl:
<img src=x onerror="alert(document.cookie)">
Every visitor who opens the page now runs that script, because innerHTML parses the note as HTML,
the broken image fails to load, and its onerror handler runs. The API accepted it correctly — it is
just text. The page turned it into code. With textContent, the browser shows those characters
literally and runs nothing. Two ways to hold the rule: data from a user is a quotation, and
textContent puts it in quotation marks; or, the page decides what is code, and nothing that arrives
over the network gets a vote.
Hosting it and letting it call the API
Host the page exactly as in Unit 3, in a new bucket hoc-site-<your-initials>-<random-digits> in the
same Region:
- Properties → Static website hosting → Edit → Enable, Index document
index.html, Save changes. Note the Bucket website endpoint. - Permissions → Block public access (bucket settings) → Edit, clear Block all public
access, Save changes. The S3 tutorial warns that this makes the bucket readable by anyone;
for a page meant to be public, that is the intent. If your account also blocks public access
(S3 console → Account and organization settings), S3 applies the more restrictive of the two,
so the bucket stays private until that setting is cleared too; the project walks through it. If an
organization that manages your account forbids clearing it, the project’s Task 1 gives a second
route: keep the bucket private and open the page through a presigned URL, a link that the S3
guide says grants time-limited access to one object without changing the bucket policy. The page
then has an
https://origin, and that is the origin CORS must allow. - Permissions → Bucket policy → Edit, allow
s3:GetObjectonarn:aws:s3:::<bucket>/*for the principal*, Save changes. - Put your real function URL into
FUNCTION_URLand uploadindex.html.
Then, on the function — Configuration → Function URL → Edit — tick Configure cross-origin resource sharing (CORS) and set:
| Setting | Value |
|---|---|
| Allow origin | http://hoc-site-…-….s3-website.ap-south-1.amazonaws.com — your endpoint, http://, no trailing slash |
| Allow methods | GET, POST |
| Allow headers | content-type |
Open the website endpoint. Add a note. If the browser’s Console shows a CORS error, compare the
origin in the error message with the one you allowed, character by character; the usual culprit is
https for http, or a trailing /. In the Network tab you can see the OPTIONS preflight
before each POST, and an access-control-allow-origin response header naming your page.
Why the page is HTTP. The S3 documentation is explicit that website endpoints do not support HTTPS, and points to Amazon CloudFront or AWS Amplify Hosting when you need it. For a practice project that only shows notes you typed, HTTP is acceptable; for anything with sign-in or personal data it is not, and that is one of the next steps listed at the end.
Watching it: metrics, alarms and missing data
Lambda publishes metrics for every function without any setup. Three matter here:
| Metric | Counts | Read it as |
|---|---|---|
Invocations |
Every time your code ran, successful or not | Traffic |
Errors |
Invocations that ended in a function error — an exception your code threw, or one the runtime threw such as a timeout | Breakage |
Throttles |
Requests refused because no concurrency was available | Capacity, or a parking brake you forgot to release |
The Lambda documentation says to view these with the Sum statistic, and gives the example of Sum
over a 1-minute period as the count of errors per minute.
Notice what Errors does not count. When the function receives {"text": ""} and returns a 400
response, it has done its job: the invocation completed normally, and from Lambda’s point of view it
is a success. Only an exception, or a runtime failure like a timeout, is an error. That is exactly
what you want an alarm to wake you for — a user typing nothing is not an incident; a table that has
disappeared is.
An alarm watches one metric and asks, every period: is the statistic beyond the threshold? For this function:
| Alarm setting | Value | Why |
|---|---|---|
| Namespace, metric, dimension | AWS/Lambda, Errors, FunctionName = hoc-notes-api |
One function’s breakage |
| Statistic, period | Sum, 1 minute |
Count of errors per minute |
| Condition | Greater than or equal to 1 |
One real exception is worth an email for a project this size |
| Datapoints to alarm | 1 out of 1 | React to the first bad minute |
| Missing data treatment | notBreaching — missing data counts as within the threshold |
See below |
| Action | Notify an SNS topic with your email subscribed | So you hear about it |
Why notBreaching. Show the surprise first: create the alarm with the defaults, leave the site
alone overnight, and in the morning the alarm reads INSUFFICIENT_DATA, grey on the dashboard.
Nothing is wrong. Lambda emits invocation metrics when the function is invoked; a function nobody
called produced no data points, and the default treatment, missing, turns a run of empty periods
into “I cannot tell”. The CloudWatch documentation’s own advice for a metric that “generates data
points only when an error occurs” is to treat missing data as notBreaching. For an error count on a
quiet function, no data genuinely means no errors, so the alarm should read OK.
Two ways to think about the four treatments: they are what a night guard writes in the log for an
hour when the camera recorded nothing — “all clear” (notBreaching), “assume the worst”
(breaching), “same as last hour” (ignore) or “unknown” (missing). Or as a rule of thumb: if
silence is normal for this metric, silence is good news; if silence means the reporter itself has
died — a heartbeat — silence is bad news.
SNS and the confirmation step. The alarm publishes to an SNS topic; you subscribe your email to the topic. The SNS documentation states that you must confirm the subscription — the email from AWS has a Confirm subscription link — before the address receives anything, and that unconfirmed subscriptions are deleted automatically after 48 hours (Amazon SNS email subscription setup and management: “Amazon SNS deletes all other unconfirmed subscriptions after 48 hours”). An alarm that changes state while the subscription says Pending confirmation sends nothing, and gives no error.
From the CLI, the whole watch takes three commands. Create the topic and subscribe:
aws sns create-topic --name hoc-alerts --tags Key=project,Value=hands-on-cloud
aws sns subscribe --topic-arn arn:aws:sns:ap-south-1:111122223333:hoc-alerts \
--protocol email --notification-endpoint [email protected]
The second prints "SubscriptionArn": "pending confirmation" until you click the link. Then the
alarm:
aws cloudwatch put-metric-alarm \
--alarm-name hoc-notes-api-errors \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=hoc-notes-api \
--statistic Sum --period 60 --evaluation-periods 1 \
--threshold 1 --comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:ap-south-1:111122223333:hoc-alerts
--period must be 10, 20, 30 or a multiple of 60 seconds. This alarm carries no tag, so the final
teardown has to find it by name — which is the point of the failure at the top of these notes. In the console the same alarm is
CloudWatch → Alarms → All alarms → Create alarm → Select metric, then the conditions, the
notification and a name on the following pages.
Testing it two ways. An alarm you have never seen fire is an assumption. First test the plumbing:
aws cloudwatch set-alarm-state --alarm-name hoc-notes-api-errors \
--state-value ALARM --state-reason "testing the email path"
The documentation says this temporarily sets the state, invokes the action for that state, and that
the alarm returns to its real state quickly, often within seconds — so you will see it in the alarm’s
History tab and in your inbox, not on the list page. Then test the real thing: set the function’s
TABLE_NAME variable to a table that does not exist, add a note from the page, and watch. The
function’s put_item call raises an exception, Errors records 1, and within a few periods the
alarm moves to ALARM and emails you. The log stream for that request shows the
ResourceNotFoundException. Set TABLE_NAME back; the next quiet minutes return the alarm to OK.
Tearing it down: inventory, order, proof
Teardown is three separate jobs, and skipping any one of them is how resources survive.
1. Inventory — find everything, not just what is tagged. Start with Tag Editor: choose your
Region, All resource types, tag key project, value hands-on-cloud, Search resources. The
Tag Editor guide makes three points worth knowing here: the search only returns resources that carry
the tag; tag searches are case sensitive, so Hands-On-Cloud is a different value; and it only
searches the Regions you choose. Then check, by name, the things a tag search predictably misses:
- log groups Lambda created for you (
/aws/lambda/…); - alarms you created without a tag;
- IAM roles and policies, which are not Regional — open the IAM console’s Roles list directly;
- anything from earlier units you did not tag: EC2 instances, security groups, key pairs, VPCs and their subnets, gateways and route tables, S3 buckets.
2. Order — dependents first. Delete what uses something before the thing it uses. A dependency deleted first does not always cause an error; it leaves the dependent broken, still present, and easy to forget. For this application:
| Step | Delete | Before | Reason |
|---|---|---|---|
| 1 | The alarm | The SNS topic | The alarm’s action points at the topic; CloudWatch does not check that an action still exists |
| 2 | The Lambda function (its URL goes with it) | Its log group, its role and its table | While the function exists, a request can recreate the log group or fail against a missing table and trip the alarm |
| 3 | The objects in the bucket | The bucket | S3 deletes only an empty bucket |
| 4 | The log group, the role, the table, the topic, the bucket | — | Nothing depends on them any more |
Three behaviours the documentation is explicit about:
- S3: a bucket must be empty to delete.
aws s3 rb s3://<bucket> --forceempties and deletes a bucket that does not have versioning enabled. Once deleted, the name returns to the shared global namespace and another account can create a bucket with the same name. Nothing points at your practice bucket, so that is harmless here; it is the reason not to delete a bucket name that real links still use. - IAM: the console removes a role’s policies when you delete it; the CLI does not — you must
detach managed policies and delete inline ones first, or
delete-rolefails. - SNS and DynamoDB: deleting a topic deletes all its subscriptions; deleting a table deletes all
its items, and the table passes through a
DELETINGstate before it is gone.
3. Proof — listings first, then the bill. Proof that a resource is gone comes from the service
that would hold it: aws lambda get-function, aws dynamodb describe-table, aws iam get-role and
the like report not found, or a console list no longer shows the item. Run one for every line of
your inventory. Then cross-check the account the way AWS will charge it. In the Billing and Cost
Management console: the Credits page shows your
remaining credit balance; the Free Tier page shows usage against free allowances; the Bills
page shows charges by service for the month. The console home’s cost and usage widget shows your
plan’s credit balance and days remaining. AWS also emails periodic alerts about your credit balance
and when a Free account plan nears its end, and Free Tier usage alerts when you pass 85 percent of a
service’s free limit. Billing pages record charges and usage; they are not a live list of what is
running, and usage inside an Always Free allowance may show no charge at all. So treat them as a
cross-check: if a service you believe you removed keeps showing new usage, go back to step 1 and list
that service’s resources directly.
What happens if you simply walk away from a Free account plan? The Free Tier FAQ says that when the Free plan expires, AWS suspends the account; the data is kept for 90 days, during which you can upgrade to a Paid plan, after which the account and its content are permanently erased. Walking away is therefore not dangerous on that plan — but it is not a teardown either, and on a Paid plan the same forgotten resources would be billed.
Three situations, then the rule
- Your alarm has read
INSUFFICIENT_DATAsince you created it, and you have never had an email. The function works. You created the alarm with default missing-data treatment, and nobody has used the site. - You open your page at its
http://website endpoint. It loads, but the list stays empty under a Could not load notes message.curl -s "$URL"in your terminal lists every note. The browser’s Console shows a CORS error naming your page’s origin: when you filled in Allow origin, you typedhttps://in front of the endpoint, or left a trailing/. The function answered, but its CORS setting did not allow the page’s origin, so MDN’s rule applies — the browser does not hand the response to the page’s script, and only the console says why.curlis not a page’s script, so it never met the check. - A month after the course, your Bills page shows a small charge you did not expect. Tag Editor finds nothing. The instance from Unit 4 was stopped, not terminated, and was never tagged. The EC2 guide says a stopped instance is not charged for usage, but its attached EBS volumes persist and are charged for storage, and an Elastic IP address associated with it is charged while it is stopped. Only terminating the instance, and releasing any Elastic IP, ends those charges.
| Symptom | Where to look | Rule |
|---|---|---|
Alarm never leaves INSUFFICIENT_DATA |
The alarm’s missing data treatment | Silence on an error metric is good news: notBreaching |
Works in curl, blank in a browser |
The browser’s Console, then the CORS origin | The origin must match exactly: scheme, host, no path |
| A charge after teardown, nothing tagged | The Bills page by service, then that service’s own list | The bill points; the service’s listing proves |
Flashcards
Say the answer aloud before revealing it. Speaking it is what exposes the gaps that reading past a written answer hides.
Why does the page use textContent and not innerHTML for notes?
A note is untrusted input. innerHTML parses it as HTML, so a note containing a script-bearing tag runs in every visitor’s browser; textContent shows it as plain characters.
The input has maxlength="500". Why does the function still check the length?
The browser limit only binds people using that page. Anyone can call the function URL directly with curl, so every rule that matters is enforced on the server.
What is the website endpoint format for a bucket in ap-south-1?
http://<bucket>.s3-website.ap-south-1.amazonaws.com, HTTP only; S3 website endpoints do not support HTTPS.
Does a function returning a 400 response count in the Errors metric?
No. Errors counts invocations ending in a function error — an exception from your code or the runtime. A response returned normally, whatever its status code, is a successful invocation.
Which statistic and period give errors per minute?
Sum over a 1-minute period; the documentation recommends Sum for invocation metrics.
Why treat missing data as notBreaching on this alarm?
A function nobody calls produces no data points. With the default, missing, the alarm sits in INSUFFICIENT_DATA. No data on an error count means no errors, so the alarm should read OK.
The alarm fired but no email arrived. First thing to check?
Whether the email subscription was confirmed. Until you click Confirm subscription, it is pending and receives nothing; unconfirmed subscriptions are deleted after 48 hours.
How do you test an alarm's notification without breaking anything?
aws cloudwatch set-alarm-state --state-value ALARM. It changes the state temporarily and runs the action; the alarm returns to its real state quickly.
What three things does a Tag Editor search miss?
Resources without the tag (or with a different-case value), resources in Regions you did not select, and resource types Tag Editor does not support. IAM roles are not Regional, so check them in IAM directly.
State the teardown ordering rule, with one example.
Delete what uses something before the thing it uses: the alarm before its SNS topic, the function before its role and table, the bucket’s objects before the bucket.
What does the CLI make you do before aws iam delete-role that the console does for you?
Detach every managed policy and delete every inline policy; otherwise the deletion fails.
How do you prove a resource is gone, and where does the bill fit?
Ask the service that would hold it: a describe, get or list command (or the console list) that reports it not found. The Bills, Credits and Free Tier pages are a cross-check afterwards — they record charges and usage, not what is running right now, and usage inside an Always Free allowance may show no charge.
Model answer
The question: “You built a small serverless app for practice. How did you make sure you’d know if it broke, and how did you make sure it wasn’t costing anything once you were done?”
There are five beats, in this order. Missing any one is a failure state: the first two are how you would know, the last three are how you would be sure, and an answer with only one half has answered half the question.
- The signal. Name the metric and why that one: Lambda’s
Errors, because it counts exceptions and timeouts, not normal 4xx responses, so it fires on breakage and not on user mistakes. - The alarm and who it reaches.
Sumper 60-second period, at least one error, missing data asnotBreaching, notifying an SNS topic with a confirmed email subscription — and proved by forcing the state and by breaking the function for real. - The inventory. Tags to find what you labelled, then a deliberate check of what tags miss: auto-created log groups, untagged resources, other Regions, IAM.
- The order. Dependents first: alarm before topic, function before role, table and log group, objects before bucket.
- The proof. A describe or list command per inventory line that reports not found, then the Bills, Credits and Free Tier pages as a cross-check, with the budget alert left on.
Spoken, all five beats sound like this:
“To know if it broke, I watched Lambda’s
Errorsmetric, because it counts exceptions and timeouts but not the 400s my code returns on purpose for bad input — so it fires on breakage, not on user mistakes. The alarm sums errors per minute and goes off on the first one, treats missing data as not breaching so a quiet evening reads as healthy, and emails me through an SNS topic whose subscription I confirmed; I proved it by forcing the alarm state and then by breaking the function for real. To be sure it cost nothing afterwards, I started with an inventory: a Tag Editor search for my project tag, then a deliberate check of what tags miss — the log group Lambda made for me, anything I forgot to tag, IAM roles. I deleted dependents first: the alarm before its topic, the function before its role, table and log group, the objects before the bucket. And I proved it with each service’s own listing reporting not found, then looked at the Bills, Credits and Free Tier pages as a cross-check, with my budget alert still on.”
Beat 5 is the one most answers drop. “I deleted everything” is a claim about your memory; a listing that reports not found is evidence.
Why this matters
- The demo that bills for a year. A student builds a project for an interview, stops the server but never terminates it, and never tags it. Its storage volume is still billed while it sits stopped, and nobody looks at the account again until the charge arrives. A teardown checklist and a budget alert are what stop this from happening to you, or catch it while the amount is small.
- The alarm that cried wolf, or never cried. An alarm on a quiet metric left on default missing data sits grey, and people learn to ignore grey. An alarm whose email was never confirmed is silent on the one night it matters. Both look configured.
- The comment box that ran someone else’s script. A page that renders user text with
innerHTMLis open to cross-site scripting: MDN callsinnerHTMLprobably the most common vector for it, and recommendstextContentfor text that should stay plain. The fix is the one-word difference you used here. - The question about cost. If someone asks about a project on your CV, “how would you keep this cheap?” is a fair question to be ready for. A concrete answer — services with Always Free allowances, one alarm, tagged resources, dependency-ordered teardown, proved with listings and cross-checked on the Bills page — shows you have run a system, not only described one.
After this unit
The project for this unit is Ship the Notes App, Prove the Alarm, and Leave Nothing Behind
(project.md). You will host the page, connect it to the API with CORS for one origin, set up and
prove the alarm two ways, then remove every resource the whole course created and record the
listings that prove it, with the billing pages as a cross-check. The final output is a short write-up of the application you built and removed,
which is the kind of thing worth keeping for an interview.
Where to go next, in roughly the order the work above suggests:
- Describe the stack as code. The CloudFormation user guide describes a template that lists your resources, from which CloudFormation creates them as one stack — and deleting the stack deletes all the resources in it, which turns this unit’s teardown into a single step.
- Serve the site over HTTPS. The S3 documentation’s recommended routes are AWS Amplify Hosting or Amazon CloudFront in front of the bucket, which also lets you keep Block Public Access on.
- Give the API more control. Amazon API Gateway, which the Lambda documentation compares with function URLs, adds features such as request validation and more options for authorisation.
- Design the table for its queries. A
scanreads every item. Learning DynamoDB key design, so that each request reads only what it needs, is the step from a practice table to a real one.
Sources
Every page below was fetched on 2026-10-02.
- Website endpoints (Amazon S3) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteEndpoints.html (fetched 2026-10-02)
- Tutorial: Configuring a static website on Amazon S3 — https://docs.aws.amazon.com/AmazonS3/latest/userguide/HostingWebsiteOnS3Setup.html (fetched 2026-10-02)
- Using cross-origin resource sharing (CORS) (Amazon S3) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html (fetched 2026-10-02)
- Deleting a general purpose bucket — https://docs.aws.amazon.com/AmazonS3/latest/userguide/delete-bucket.html (fetched 2026-10-02)
- Creating and managing Lambda function URLs — https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html (fetched 2026-10-02)
- Types of metrics for Lambda functions — https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html (fetched 2026-10-02)
- Using Amazon CloudWatch alarms — https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html (fetched 2026-10-02)
- Create a CloudWatch alarm based on a static threshold — https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ConsoleAlarms.html (fetched 2026-10-02)
- Configuring how CloudWatch alarms treat missing data — https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/alarms-and-missing-data.html (fetched 2026-10-02)
- AWS CLI
cloudwatch put-metric-alarm— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/put-metric-alarm.html (fetched 2026-10-02) - AWS CLI
cloudwatch set-alarm-state— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/set-alarm-state.html (fetched 2026-10-02) - AWS CLI
cloudwatch delete-alarms— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/delete-alarms.html (fetched 2026-10-02) - Amazon SNS email subscription setup and management — https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html (fetched 2026-10-02)
- AWS CLI
sns create-topic— https://docs.aws.amazon.com/cli/latest/reference/sns/create-topic.html (fetched 2026-10-02) - AWS CLI
sns delete-topic— https://docs.aws.amazon.com/cli/latest/reference/sns/delete-topic.html (fetched 2026-10-02) - Finding resources to tag (Tag Editor) — https://docs.aws.amazon.com/tag-editor/latest/userguide/find-resources-to-tag.html (fetched 2026-10-02)
- AWS CLI
iam delete-role— https://docs.aws.amazon.com/cli/latest/reference/iam/delete-role.html (fetched 2026-10-02) - AWS CLI
dynamodb delete-table— https://docs.aws.amazon.com/cli/latest/reference/dynamodb/delete-table.html (fetched 2026-10-02) - Tracking your AWS Free Tier usage — https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/tracking-free-tier-usage.html (fetched 2026-10-02)
- AWS Free Tier FAQs — https://aws.amazon.com/free/free-tier-faqs/ (fetched 2026-10-02)
- Amazon CloudWatch pricing — https://aws.amazon.com/cloudwatch/pricing/ (fetched 2026-10-02)
- Explore AWS services with AWS Free Tier — https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/free-tier.html (fetched 2026-10-02)
- MDN, Element: innerHTML property (security considerations) — https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML (fetched 2026-10-02)
- MDN, Node: textContent property — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent (fetched 2026-10-02)
- MDN, HTML attribute: maxlength — https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/maxlength (fetched 2026-10-02)
- What is CloudFormation? — https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html (fetched 2026-10-02)
- How EC2 instance stop and start works (stopped: no usage charge; EBS storage and associated Elastic IP addresses charged) — https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/how-ec2-instance-stop-start-works.html (fetched 2026-10-02)
- Amazon S3 pricing — https://aws.amazon.com/s3/pricing/ (fetched 2026-10-02)
- Blocking public access to your Amazon S3 storage — https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html (fetched 2026-10-02)
- Download and upload objects with presigned URLs — https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html (fetched 2026-10-02)
- Login for AWS local development using console credentials (
aws login, minimum CLI 2.32.0) — https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sign-in.html (fetched 2026-10-02) - MDN, Cross-Origin Resource Sharing (CORS) (a browser restriction on script requests such as
fetch(); failure details only in the console) — https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS (fetched 2026-10-02)
Project
Hands-on Project — Ship the Notes App, Prove the Alarm, and Leave Nothing Behind
Objective
Put a web page in front of your notes API so a browser can list and add notes, make the application email you when it breaks — and prove that it does — and then remove every resource the course created, proving each one gone with the listing of the service that held it. You finish holding a short write-up of a complete application you built, watched and removed, and an AWS account in which every resource this course created is gone, with your sign-in protections and your budget alert kept.
What you need: your AWS account signed in as your everyday administrator identity, the AWS CLI
with short-lived credentials, curl, a desktop browser with developer tools, an email inbox you can
open now, and the notes API: the Lambda function hoc-notes-api with a public function URL, its role
hoc-notes-api-role, and the DynamoDB table hoc-notes. If any of those is missing, build it first
with the appendix at the end of this file. Region: the one you have used throughout; the examples
say ap-south-1 and use 111122223333 as the account ID — substitute your own.
If you are starting here without the earlier units, set up three things before the appendix:
- An everyday sign-in that is not the root user. The IAM guide says not to use root credentials for daily tasks. As root, open IAM → Users → Create user, give the user console access and administrator permissions with a permissions policy, and sign in as that user.
- A budget alert. Billing and Cost Management → Budgets → Create budget → Use a template (simplified) → Zero spend budget. It watches what you spend, not how much you use: the template page says it notifies you after your spending exceeds the Free Tier limits.
- The AWS CLI. Install AWS CLI version 2;
aws --versionmust report 2.32.0 or later, the minimum the CLI guide gives foraws login. Runaws login, enter your Region when asked, and finish the sign-in in the browser. Check withaws sts get-caller-identity; itsAccountfield is your account ID.
Then build the notes API from the appendix and come back here.
Cost. This project uses S3, Lambda, DynamoDB, CloudWatch and SNS. The Free Tier tracking guide lists Lambda, DynamoDB, CloudWatch and SNS usage as Always Free types, but do not assume your account gets them: the Free Tier guide says the offers you have depend on your account plan (Free or Paid), and the tracking guide says Free Tier data may not show if your Free Tier has expired or you sign in through an organization member account. Open your own Free Tier and Credits pages in Billing and Cost Management to see what applies to you. S3 is not on that list: the S3 pricing page charges standard storage per GB per month and says Free Tier credits can be applied to S3, so one small HTML file is a tiny fraction of one billed unit; your own Free Tier and Credits pages show how it is counted. An allowance is not a guarantee: the billing guide says usage beyond an allowance is covered by your Free Tier credits, and charged on a Paid plan once credits are gone. Nothing here needs a paid feature, and your budget alert stays on throughout.
Keep one plain-text file open, unit8-evidence.md, and start it from this skeleton. Every task
says which lines to fill. Keep each label exactly as written, one value per line after the colon,
and paste command output and header lines exactly as the tool printed them.
## Task 1
route:
account BPA:
page origin:
allow-origin header:
bold note:
## Task 2
email subject:
history:
page in Test 2:
log error name:
## Task 3 — inventory
## Task 3 — checklist
- [ ] CloudWatch alarm
- [ ] SNS topic
- [ ] Lambda function
- [ ] Log group
- [ ] Role
- [ ] Customer-managed policies
- [ ] DynamoDB table
- [ ] hoc- buckets
- [ ] EC2 instances
- [ ] Custom VPC
- [ ] Default-VPC security groups and key pairs
- [ ] Account Block Public Access restored
## Task 3 — after
## Task 3 — services
| service | listing showed | billing page showed |
|---|---|---|
Where a label can take more than one value (email subject:, history:), repeat the whole line
once per value.
Task 1 — Ship the page
Scope: one bucket, one HTML file, one CORS setting. No custom domain, no HTTPS for the page.
-
Unpark the function if you parked it at the end of Unit 7. There were two ways to park it, and each is undone only by its own reverse, so first find out which one applies:
aws lambda get-function-concurrency --function-name hoc-notes-api aws lambda get-function-url-config --function-name hoc-notes-api --query AuthType- If the first prints
"ReservedConcurrentExecutions": 0, the function is parked by reserved concurrency. Remove the setting:aws lambda delete-function-concurrency --function-name hoc-notes-api. The function URLs guide says a URL deactivated this way is reactivated by deleting the reserved concurrency configuration (or setting it above zero). Changing the auth type does not undo this kind of parking: every request would still be throttled. - If the second prints
"AWS_IAM", the URL is parked by its auth type. Set it back toNONEunder Configuration → Function URL → Edit. Then open Configuration → Permissions → Resource-based policy and check that both public statements are there — one allowinglambda:InvokeFunctionUrl, one allowinglambda:InvokeFunction— because the access-control guide says a URL with auth typeNONEstill returns 403 without them. If either is missing, add it with the twoadd-permissioncommands in the appendix. - If neither applies, the function is not parked; go on.
Check it answers. First put your function URL in a shell variable — the console shows it under Configuration → Function URL, and
aws lambda get-function-url-config --function-name hoc-notes-api --query FunctionUrlprints it:URL="https://abc123example.lambda-url.ap-south-1.on.aws/" curl -s "$URL"It must print
{"notes": [...]}. Ifget-function-url-configreports that the resource is not found, the function or its URL is missing: build it with the appendix, then come back. - If the first prints
-
Create the bucket. S3 console → Create bucket → Bucket type general purpose, name
hoc-site-<your-initials>-<random-digits>(the examples usehoc-site-ab-4821), your Region, every other setting left at its default → Create bucket. -
Website hosting: open the bucket → Properties → Static website hosting → Edit → Enable, Index document
index.html, Save changes. Copy the Bucket website endpoint. (Route B in step 4 does not use this endpoint; it does no harm to enable it anyway.) -
Public read. First check the account-wide setting: the S3 guide says S3 applies the most restrictive combination of the account and bucket settings, so if the account blocks public access, clearing the bucket’s setting changes nothing. In the S3 console’s navigation pane choose Account and organization settings and look under Block Public Access settings for this account. If Block all public access is on, choose Edit, clear it, Save changes, type
confirmand choose Confirm. This applies to every bucket in your account, in every Region, so writeaccount BPA: turned offinunit8-evidence.mdand turn it back on in Task 3. If it was already off, writeaccount BPA: unchanged.If S3 refuses with a message that the account does not allow changes to its account-level S3 Block Public Access settings due to an organizational S3 Block Public Access policy, your account is managed by an organization and the bucket cannot be made public. Do not stop: follow Route B below instead of the rest of this step, and write
route: Route B: organization policyandaccount BPA: unchangedinunit8-evidence.md. Otherwise writeroute: Route A. Everything after Task 1 works the same on either route.Route A — public website (the account allows it). Then the bucket: Permissions → Block public access (bucket settings) → Edit, clear Block all public access, Save changes. Then Permissions → Bucket policy → Edit, paste this policy with your bucket name, Save changes:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::hoc-site-ab-4821/*"] } ] }Route B — private bucket and a presigned URL (the account does not allow public access). Leave both Block Public Access settings on and add no bucket policy. Do step 5 (upload the page) as normal. Then, instead of the website endpoint, give your browser a presigned URL: the S3 guide says a presigned URL grants time-limited access to an object without updating your bucket policy, using the permissions of whoever created it. In the S3 console open the bucket, select
index.html, choose Object actions → Share with a presigned URL, set the longest time the console allows (the guide gives 12 hours), and choose Create presigned URL; it is copied to your clipboard. Two things to know:- Your page’s origin is now the start of that URL up to, but not including, the first
/afterhttps://—https://followed by a host name that begins with your bucket name. Copy it from your own URL rather than typing it. Use that exact text, with nothing after the host, wherever step 6 and Task 1’s evidence ask for the page’s origin. - A presigned URL stops working when its time runs out, or earlier if the credentials that signed it expire, which the guide says happens with temporary credentials. If the page later fails to load with an access-denied or expired-token message, create a new presigned URL the same way and open that one.
Anyone who has a presigned URL can open the page until it expires, so share it with no one.
- Your page’s origin is now the start of that URL up to, but not including, the first
-
The page: save this as
index.html, replace theFUNCTION_URLvalue with your own function URL, and upload it to the bucket (Objects → Upload):<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Notes</title> </head> <body> <h1>Notes</h1> <form id="add"> <input id="text" maxlength="500" placeholder="Write a note" required> <button type="submit">Add</button> </form> <p id="message"></p> <ul id="notes"></ul> <script> const FUNCTION_URL = "https://abc123example.lambda-url.ap-south-1.on.aws/"; const list = document.getElementById("notes"); const message = document.getElementById("message"); async function loadNotes() { const response = await fetch(FUNCTION_URL); const data = await response.json(); list.replaceChildren(); for (const note of data.notes) { const item = document.createElement("li"); item.textContent = note.text ?? note.title; // Unit 6 notes have a title list.appendChild(item); } } document.getElementById("add").addEventListener("submit", async (event) => { event.preventDefault(); const input = document.getElementById("text"); const response = await fetch(FUNCTION_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: input.value }), }); const data = await response.json(); message.textContent = response.ok ? "Saved." : `Not saved: ${data.error}`; if (response.ok) { input.value = ""; await loadNotes(); } }); loadNotes().catch((error) => { message.textContent = `Could not load notes: ${error}`; }); </script> </body> </html> -
CORS: on the function, Configuration → Function URL → Edit, tick Configure cross-origin resource sharing (CORS); allow origin = your page’s origin exactly — on Route A the bucket website endpoint (
http://, no trailing slash), on Route B thehttps://host of the presigned URL (no path); allow methodsGETandPOST; allow headerscontent-type; Save. -
Tag the bucket for the teardown (this replaces any tags the bucket already has, and a new bucket has none):
aws s3api put-bucket-tagging --bucket hoc-site-ab-4821 \ --tagging 'TagSet=[{Key=project,Value=hands-on-cloud}]' -
Test in the browser. Open the page — the website endpoint on Route A, the presigned URL on Route B — add two notes, and reload the page. Then add a third note whose text is
<b>bold?</b>and look at how the page displays it. Open the developer tools Network tab, add one more note, and find theOPTIONSrequest and thePOSTthat follows it.
Fill the Task 1 lines in unit8-evidence.md:
route:andaccount BPA:— as step 4 told you.page origin:— the website endpoint (Route A) or the presigned URL’shttps://host (Route B), with no path and no trailing/.allow-origin header:— from thePOST’s Response Headers, theaccess-control-allow-originline exactly as the developer tools show it, name and value.bold note:— one sentence saying how the<b>bold?</b>note was displayed, and why.
Time budget: 1 hour
Output: a working page, and unit8-evidence.md holding the route, the page’s origin, the copied
header line, and your one-sentence description and explanation.
Task 2 — Make it watch itself, and prove it twice
Scope: one topic, one email subscription, one alarm, two tests.
-
Topic and subscription — put in your Region, account ID and email address:
aws sns create-topic --name hoc-alerts --tags Key=project,Value=hands-on-cloud aws sns subscribe --topic-arn arn:aws:sns:ap-south-1:111122223333:hoc-alerts \ --protocol email --notification-endpoint [email protected]Open the email from AWS and choose Confirm subscription. Do not continue until you have: the SNS guide says an email address receives messages only once its subscription is confirmed, and that unconfirmed subscriptions are deleted after 48 hours. Check it:
aws sns list-subscriptions-by-topic \ --topic-arn arn:aws:sns:ap-south-1:111122223333:hoc-alerts \ --query 'Subscriptions[].SubscriptionArn'A confirmed subscription shows a full ARN ending in an ID; one still waiting shows
"PendingConfirmation". If no email arrives, look in your spam folder and check the address you typed; if the address was wrong, run thesubscribecommand again with the right one and confirm that email instead. -
The alarm — sums the function’s errors per minute, goes to
ALARMon the first one, treats a quiet minute as healthy, and notifies the topic. Put in your account ID and run:aws cloudwatch put-metric-alarm \ --alarm-name hoc-notes-api-errors \ --namespace AWS/Lambda --metric-name Errors \ --dimensions Name=FunctionName,Value=hoc-notes-api \ --statistic Sum --period 60 --evaluation-periods 1 \ --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold \ --treat-missing-data notBreaching \ --alarm-actions arn:aws:sns:ap-south-1:111122223333:hoc-alerts -
Test 1 — the plumbing:
aws cloudwatch set-alarm-state --alarm-name hoc-notes-api-errors \ --state-value ALARM --state-reason "testing the email path"Check your inbox, then the alarm’s History tab in the CloudWatch console. The
set-alarm-statereference says a metric alarm returns to its actual state quickly, often within seconds, so the change shows in History rather than on the alarms list. -
Wait for
OKbefore the real test. The alarm’s email goes out when the alarm moves intoALARMfrom another state — theput-metric-alarmreference describes--alarm-actionsas the actions run on that transition. If Test 2 starts while the alarm still readsALARM, there is no new transition and no new email. Check the state:aws cloudwatch describe-alarms --alarm-names hoc-notes-api-errors \ --query 'MetricAlarms[0].StateValue'Run it again after each alarm period (the
--period 60you set) until it prints"OK", then go on.- If it prints
"INSUFFICIENT_DATA", the alarm has not been evaluated yet: the same reference says a new alarm starts in that state and is then evaluated. Keep checking; a move fromINSUFFICIENT_DATAintoALARMwould also send the email, butOKis the clean start. - If it keeps printing
"ALARM", the function is really failing. Runcurl -s "$URL": if it does not list notes, check thatTABLE_NAMEishoc-notes(Configuration → Environment variables) and fix it, then check again until it prints"OK". If it does list notes, the errors have stopped; the next evaluated period with no errors returns the alarm toOK, because the alarm treats a quiet period as not breaching.
- If it prints
-
Test 2 — a real failure. Set the function’s environment variable
TABLE_NAMEtohoc-notes-missing(Configuration → Environment variables → Edit). Add three notes from the web page, noting what the page shows each time. Check the alarm with the samedescribe-alarmscommand after each alarm period until it prints"ALARM", and watch your inbox. Open the function’s newest log stream (Monitor → View CloudWatch logs) and find the error for one of those requests.- If the alarm still prints
"OK"after several periods, check that the change toTABLE_NAMEwas saved, and that the log stream shows an error for your requests: only an invocation that ends in an error counts in theErrorsmetric, so the alarm has nothing to count without one. Add another note once both are true, and check again.
- If the alarm still prints
-
Restore. Set
TABLE_NAMEback tohoc-notes, add a note successfully, and run thedescribe-alarmscommand after each alarm period until it prints"OK"again.
Fill the Task 2 lines in unit8-evidence.md:
email subject:— one line per alarm email, its subject exactly as received.history:— one line per History entry from Test 1 to the finalOK, as<timestamp> | <summary>, copied from the alarm’s History tab.page in Test 2:— one sentence on what the page showed when you added the three notes.log error name:— the exception’s name from the log line, one word, no message text.
Time budget: 1 hour
Output: the Task 2 lines of unit8-evidence.md filled, the alarm reading OK, and the page
working again after the restore.
Task 3 — Tear down everything the course created, and prove it
Scope: every resource from every unit, then a listing for each. Keep only your root user’s MFA, your everyday administrator identity, and the budget alert. Do not close the account.
Step 1 — Inventory. Before deleting anything, write the list in unit8-evidence.md under
Task 3 — inventory, one resource per line, as - <service> | <name or ID> (for example
- Lambda | hoc-notes-api):
-
Tag Editor: your Region, All resource types, tag key
project, valuehands-on-cloud, Search resources. List every result. The Tag Editor guide says the search covers only the Regions you select and is case sensitive, so repeat it for any other Region you used. -
What a tag search misses, checked by name — these commands match only this course’s names:
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/hoc- --query 'logGroups[].logGroupName' aws cloudwatch describe-alarms --alarm-name-prefix hoc- --query 'MetricAlarms[].AlarmName' aws iam list-roles --query "Roles[?starts_with(RoleName, 'hoc-')].RoleName" aws s3api list-buckets --query "Buckets[?starts_with(Name, 'hoc-')].Name" aws ec2 describe-instances --filters Name=tag:project,Values=hands-on-cloud \ --query 'Reservations[].Instances[].[InstanceId,State.Name]' aws ec2 describe-vpcs --filters Name=tag:Name,Values=hoc-vpc --query 'Vpcs[].VpcId'Then look for anything from the course that was never tagged or named
hoc-: the EC2 Global View page lists instances across all Regions, and the IAM Users and Policies lists show anything you created by hand.
Step 2 — Delete, dependents first. The Task 3 — checklist section of your
unit8-evidence.md skeleton holds one line for each item below, in the same order. As you finish an
item, change that line’s - [ ] to - [x] in unit8-evidence.md. Skip an item only if your
inventory shows the resource does not exist; then leave its line as - [ ] and add
| skipped: not in inventory to the end (for the last line, | skipped: account BPA unchanged).
-
CloudWatch alarm:
aws cloudwatch delete-alarms --alarm-names hoc-notes-api-errors -
SNS topic:
aws sns delete-topic --topic-arn arn:aws:sns:ap-south-1:111122223333:hoc-alerts -
Lambda function: Lambda console → select
hoc-notes-api→ Actions → Delete, typeconfirm, Delete. The function URLs page says Lambda deletes the function’s URL with it. -
Its log group:
aws logs delete-log-group --log-group-name /aws/lambda/hoc-notes-api -
The role: IAM console → Roles →
hoc-notes-api-role→ Delete, type the name, Delete. Thedelete-rolereference says the console removes the role’s policies with it, while the CLI makes you detach and delete them first. -
Any customer-managed policy you created for the course, such as
hoc-notes-table-access: IAM console → Policies → select it → Delete. -
The table:
aws dynamodb delete-table --table-name hoc-notes -
Every
hoc-bucket, emptied and deleted:aws s3 rb s3://<bucket> --force. The S3 guide says this works for a bucket without versioning enabled, which is the case for the buckets in this course. -
Every EC2 instance from the course: Instance state → Terminate. The EC2 guide says a stopped instance is not deleted, so stopping is not enough.
-
Any custom VPC, in the order the VPC guide gives for the command line — or delete it from the VPC console, which the guide says also deletes its subnets, route tables, internet gateways and security groups once no instances remain:
``` aws ec2 delete-security-group --group-id <sg-id> # each security group you created aws ec2 delete-subnet --subnet-id <subnet-id> # each subnet aws ec2 delete-route-table --route-table-id <rtb-id> # each custom route table aws ec2 detach-internet-gateway --internet-gateway-id <igw-id> --vpc-id <vpc-id> aws ec2 delete-internet-gateway --internet-gateway-id <igw-id> aws ec2 delete-vpc --vpc-id <vpc-id> ``` -
Security groups you created in the default VPC, and any key pairs you created.
-
If you turned off the account-wide Block Public Access in Task 1: S3 console → Account and organization settings → Edit, tick Block all public access, Save changes, type
confirm, Confirm.
Step 3 — Prove it. For each line of your inventory, run the listing that would show it — the Step 1 commands, the Tag Editor search, and for single resources a direct check such as:
aws lambda get-function --function-name hoc-notes-api
aws dynamodb describe-table --table-name hoc-notes
aws sns get-topic-attributes --topic-arn arn:aws:sns:ap-south-1:111122223333:hoc-alerts
Write one line under Task 3 — after for every inventory line, as
- <service> | <name or ID> | <command you ran> | <result>, where the result is the empty list or
the not-found error exactly as printed. Then, as a cross-check rather than as proof, open Billing and Cost Management and look at
the Bills page by service, the Free Tier page and the Credits page. They record charges
and usage, not what is running right now, and usage inside an Always Free allowance may show no
charge; if one keeps showing new usage for a service you believe is empty, list that service’s
resources again. Leave the budget alert in place.
Time budget: 1 hour 30 minutes
Output: in unit8-evidence.md, the inventory before, the Task 3 — checklist section with
every line ticked - [x] or marked skipped, one after-line per
inventory line, and the Task 3 — services table with one row per service the course used,
recording what its listing and its billing page showed.
Task 4 — Write it up (optional extension)
Scope: one page, in your own words, no screenshots required. Stop at one page.
Write notes-app-writeup.md — the kind of summary worth keeping for an interview — with these
headings:
- What it was — the five pieces and the alarm, as a small text diagram.
- Who could call what — the function URL’s auth type, CORS for one origin, the role’s
permissions, and why the page uses
textContent. - How I knew it was healthy — the metric, the alarm settings, the missing-data choice, and the two tests with what each proved.
- How I removed it — the order you used and the listings that proved it.
- What I would change next — at least two of: HTTPS for the page, the stack described as code, API Gateway in front of the function, a table designed for its queries — and for each, one sentence on what problem it solves that you met in this course.
Time budget: 45 minutes
Output: notes-app-writeup.md, one page, with all five headings filled.
What this feeds
This is the last unit, so the output feeds forward rather than into a next unit: the write-up is a description of a real application you built, monitored and removed, with evidence for each claim. The inventory-order-proof method from Task 3 is the one to use on any account you are ever asked to clean up.
Appendix — rebuild the notes API if it is missing
Skip this if curl -s "$URL" already lists notes. Otherwise, create only what is missing. This
appendix rebuilds what Unit 7 built, as a supplied input for this unit; it needs the AWS CLI 2.32.0 or
later signed in with aws login.
The table (on-demand capacity, string partition key, tagged):
aws dynamodb create-table \
--table-name hoc-notes \
--attribute-definitions AttributeName=noteId,AttributeType=S \
--key-schema AttributeName=noteId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--tags Key=project,Value=hands-on-cloud
The role. Save as trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Save as table-policy.json, with your account ID and Region:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:Scan", "dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/hoc-notes"
}
]
}
Then:
aws iam create-role --role-name hoc-notes-api-role \
--assume-role-policy-document file://trust-policy.json \
--tags Key=project,Value=hands-on-cloud
aws iam attach-role-policy --role-name hoc-notes-api-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name hoc-notes-api-role \
--policy-name hoc-notes-table-access \
--policy-document file://table-policy.json
The function. Lambda console → Create function → Author from scratch, name hoc-notes-api,
runtime Python 3.14 (the Lambda runtimes page lists python3.14 among the supported runtimes;
if your console does not offer it, choose the newest Python runtime that page lists as supported),
Permissions → Use another role → hoc-notes-api-role → Create
function. In the Code tab, replace lambda_function.py with the code below and choose
Deploy. Under Configuration → Environment variables, add TABLE_NAME = hoc-notes.
import base64
import json
import os
import uuid
from datetime import datetime, timezone
import boto3
TABLE_NAME = os.environ.get("TABLE_NAME", "hoc-notes")
table = boto3.resource("dynamodb").Table(TABLE_NAME) # created once per environment
MAX_TEXT = 500
def respond(status, payload):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(payload, default=str),
}
def read_body(event):
body = event.get("body") or ""
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode("utf-8")
return body
def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "")
print(f"{method} {event.get('rawPath', '/')}")
if method == "GET":
items = table.scan().get("Items", [])
items.sort(key=lambda note: note.get("createdAt", ""), reverse=True)
return respond(200, {"notes": items})
if method == "POST":
try:
data = json.loads(read_body(event))
except json.JSONDecodeError:
return respond(400, {"error": "body must be JSON"})
text = str(data.get("text", "")).strip() if isinstance(data, dict) else ""
if not text:
return respond(400, {"error": "text is required"})
if len(text) > MAX_TEXT:
return respond(400, {"error": f"text must be at most {MAX_TEXT} characters"})
note = {
"noteId": str(uuid.uuid4()),
"text": text,
"createdAt": datetime.now(timezone.utc).isoformat(),
}
table.put_item(Item=note)
return respond(201, note)
if method == "OPTIONS":
return {"statusCode": 204}
return respond(405, {"error": f"method {method} not allowed"})
The URL. Configuration → Function URL → Create function URL, Auth type NONE, Save.
The console adds the public invoke permissions for you; copy the URL into $URL and check it with
curl -s "$URL".
If you ever need to add those two permissions yourself — after switching the auth type back from
AWS_IAM in Task 1, or when creating the URL from the CLI — run:
aws lambda add-permission --function-name hoc-notes-api \
--statement-id UrlPolicyInvokeURL \
--action lambda:InvokeFunctionUrl \
--principal '*' \
--function-url-auth-type NONE
aws lambda add-permission --function-name hoc-notes-api \
--statement-id UrlPolicyInvokeFunction \
--action lambda:InvokeFunction \
--principal '*' \
--invoked-via-function-url
The --invoked-via-function-url option is recent: the AWS CLI changelog shows it arriving in version
2.31.13, so an older CLI rejects it. The 2.32.0 this unit asks for is new enough.
Sources
The AWS and browser behaviour this project relies on is documented on these pages, each fetched on 2026-10-02:
- Tutorial: Configuring a static website on Amazon S3 — https://docs.aws.amazon.com/AmazonS3/latest/userguide/HostingWebsiteOnS3Setup.html
- Website endpoints (Amazon S3; HTTP only) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteEndpoints.html
- AWS CLI
s3api put-bucket-tagging(replaces existing tags) — https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-tagging.html - Blocking public access to your Amazon S3 storage (most restrictive of account and bucket settings applies) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html
- Configuring block public access settings for your account (console path; organization-policy error) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/configuring-block-public-access-account.html
- Amazon S3 pricing (storage charged per GB per month; Free Tier credits apply to S3) — https://aws.amazon.com/s3/pricing/
- Deleting a general purpose bucket — https://docs.aws.amazon.com/AmazonS3/latest/userguide/delete-bucket.html
- Creating and managing Lambda function URLs (CORS; URL deleted with its function; reserved concurrency 0 deactivates a URL, and deleting the reserved concurrency reactivates it) — https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html
- Configuring reserved concurrency for a function (
GetFunctionConcurrency,DeleteFunctionConcurrency) — https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html - Download and upload objects with presigned URLs (no bucket policy change; expiry; temporary credentials) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Sharing objects with presigned URLs (console: Share with a presigned URL, up to 12 hours) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- Login for AWS local development using console credentials (
aws login; minimum CLI 2.32.0) — https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sign-in.html - AWS CLI version 2 changelog (
InvokedViaFunctionUrladded in 2.31.13) — https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst - Setting up your AWS account (do not use root for daily tasks) — https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started-account-iam.html
- Create an IAM user in your AWS account — https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html
- Using a budget template (simplified) (Zero spend budget notifies after spending exceeds Free Tier limits) — https://docs.aws.amazon.com/cost-management/latest/userguide/budget-templates.html
- Control access to Lambda function URLs (console adds the
NONEpermissions; 403 without them; the twoadd-permissioncommands) — https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html - Lambda runtimes (supported runtimes table;
python3.14listed as supported) — https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html - AWS CLI
cloudwatch describe-alarms(--alarm-names;StateValueisOK,ALARMorINSUFFICIENT_DATA) — https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/describe-alarms.html - Define Lambda function handler in Python — https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html
- Lambda API
GetFunction(ResourceNotFoundExceptionwhen the function does not exist) — https://docs.aws.amazon.com/lambda/latest/api/API_GetFunction.html - Types of metrics for Lambda functions (
Errors) — https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html - AWS CLI
cloudwatch put-metric-alarm(--alarm-actionsrun on a transition intoALARM; a new alarm starts inINSUFFICIENT_DATA) — https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/put-metric-alarm.html - AWS CLI
cloudwatch set-alarm-state(a metric alarm returns to its actual state quickly) — https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/set-alarm-state.html - AWS CLI
cloudwatch delete-alarms— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/delete-alarms.html - Amazon SNS email subscription setup and management (confirmation; unconfirmed deleted after 48 hours; an unconfirmed subscription’s ARN reads
PendingConfirmation) — https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html - AWS CLI
sns create-topic— https://docs.aws.amazon.com/cli/latest/reference/sns/create-topic.html - AWS CLI
sns delete-topic— https://docs.aws.amazon.com/cli/latest/reference/sns/delete-topic.html - Finding resources to tag (Tag Editor: selected Regions only; case-sensitive search) — https://docs.aws.amazon.com/tag-editor/latest/userguide/find-resources-to-tag.html
- AWS CLI
iam delete-role(console removes policies; CLI does not) — https://docs.aws.amazon.com/cli/latest/reference/iam/delete-role.html - AWS CLI
dynamodb delete-table— https://docs.aws.amazon.com/cli/latest/reference/dynamodb/delete-table.html - Stop and start Amazon EC2 instances (a stopped instance is not deleted; EC2 Global View) — https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Stop_Start.html
- Delete your VPC (console and command-line order) — https://docs.aws.amazon.com/vpc/latest/userguide/delete-vpc.html
- Explore AWS services with AWS Free Tier (offers depend on the account plan; credits beyond allowances) — https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/free-tier.html
- Tracking your AWS Free Tier usage (Lambda, DynamoDB, CloudWatch, SNS listed as Always Free types; Free Tier data may not show for an expired Free Tier or an organization member account) — https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/tracking-free-tier-usage.html
- MDN, Node: textContent property — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent
Quiz
Online marking is not open yet. Work through the questions and check the written ones against the criteria printed with each question. The answer key is kept back for now.
Quiz — Unit 8: Ship It, Watch It, Tear It Down
Instructions: Ten questions in two parts, and both parts are scored.
- Questions 1–7 are multiple choice. Pick one option per question. They are marked by comparing your letter against the key.
- Questions 8–10 ask you to write a command, an order of steps, or a list. Each is marked by a script that applies the rubric held in the separate answer key. Layout, spacing and option order are not marked; only whether your answer does what the question asks.
Work closed-book on questions 1–7. On questions 8–10 you may test your answer against your own account, where that is safe, as often as you like before submitting it.
1. The notes page shows each note with item.innerHTML = note.text. Someone posts, with curl,
a note whose text is <img src=x onerror="alert(document.cookie)">. What happens, and what is the
fix?
a) The function rejects the note because it contains angle brackets, so the page is never affected
b) Nothing happens, because a browser never runs script that arrives in a JSON response body
c) The alert runs only for the person who posted it, so the fix is to validate the input length
d) Every visitor’s browser runs the handler; display notes with textContent instead of innerHTML
2. Fifty requests arrive with the body {"text": ""}, and the function returns a 400 response to
each. What does the function’s Errors metric show for those requests?
a) Fifty, because every response with a 4xx or 5xx status code is counted as a function error
b) Zero, because each invocation completed normally and returned a response, so none is an error
c) Fifty, but only if the alarm’s missing data treatment is set to treat missing data as breaching
d) Zero for the metric, but each request is counted under Throttles because it was refused
3. You create the Errors alarm with every default, and nobody uses the site for a day. In the
morning the alarm reads INSUFFICIENT_DATA. Which setting makes a quiet, healthy function read
OK, and why?
a) Treat missing data as notBreaching, because no data points on an error count means no errors
b) Treat missing data as breaching, because silence from a function means it has probably crashed
c) Treat missing data as ignore, because the alarm then always starts in the OK state it needs
d) Set the statistic to Average, because an average of no data points is calculated as zero
4. You force the alarm into ALARM with set-alarm-state. The alarm’s History tab shows the
change and the action, but no email ever arrives. What is the most likely cause?
a) set-alarm-state changes only the displayed state and never invokes the alarm’s actions at all
b) The alarm’s threshold is 1, so a forced state change is ignored until a real error is recorded
c) The email subscription to the SNS topic was never confirmed, so it is still pending confirmation
d) SNS delivers email only for alarms on metrics in the AWS/SNS namespace, not for Lambda metrics
5. Tag Editor, searching ap-south-1 across all resource types for project = hands-on-cloud,
returns nothing, yet the CloudWatch console still lists the log group /aws/lambda/hoc-notes-api.
Why did the search miss it?
a) Tag Editor never searches CloudWatch resources, so log groups have to be removed through the CLI
b) Lambda created the log group itself without your tag, and a tag search returns only tagged items
c) Log groups are global, so Tag Editor finds them only when the Region is set to us-east-1
d) The search value must be written in capitals, because Tag Editor tag searches ignore lower case
6. In what order should you delete the CloudWatch alarm hoc-notes-api-errors and the SNS topic
it notifies, and why?
a) The alarm first, because its action points at the topic and CloudWatch does not check it exists
b) The topic first, because deleting it automatically deletes every alarm that notifies the topic
c) The topic first, because SNS refuses to delete a topic while an alarm is still in the OK state
d) Either order, because an alarm and a topic are unrelated and no state is shared between them
7. You have just deleted the notes function. Which check gives direct evidence, rather than an
inference, that hoc-notes-api no longer exists?
a) The Bills page by service shows no Lambda charge this month, so the function cannot be running
b) The All alarms page shows no alarm in ALARM, so no function is still sending error metrics
c) The IAM Roles list no longer shows hoc-notes-api-role, so the function it served is gone too
d) aws lambda get-function --function-name hoc-notes-api reports that the function is not found
Part 2 — Constructed response
Submit plain text exactly as you would type it. Do not wrap your answer in quotes or code fences.
8. Write the AWS CLI command that creates an alarm named hoc-notes-api-errors on the Errors
metric of the Lambda function hoc-notes-api, counting errors per minute, going to ALARM on the
first minute with one or more errors, treating missing data as good, and notifying the topic
arn:aws:sns:ap-south-1:111122223333:hoc-alerts.
9. These eight teardown steps are listed in no particular order:
| Letter | Step |
|---|---|
| A | Delete the CloudWatch alarm hoc-notes-api-errors |
| B | Delete the SNS topic hoc-alerts |
| C | Delete the Lambda function hoc-notes-api |
| D | Delete the log group /aws/lambda/hoc-notes-api |
| E | Empty the bucket hoc-site-ab-4821 |
| F | Delete the bucket hoc-site-ab-4821 |
| G | Delete the DynamoDB table hoc-notes |
| H | Delete the IAM role hoc-notes-api-role |
Submit all eight letters in the order you would carry them out, as one string of eight capital letters with no spaces — each letter exactly once, the step you would do first on the left.
10. Every resource below is of a type Tag Editor supports. You run a Tag Editor search with Region
ap-south-1 only, All resource types, and the tag key project with value
hands-on-cloud.
| # | Resource | Region | Tags |
|---|---|---|---|
| 1 | Lambda function hoc-notes-api |
ap-south-1 |
project = hands-on-cloud |
| 2 | DynamoDB table hoc-notes |
ap-south-1 |
project = hands-on-cloud |
| 3 | Log group /aws/lambda/hoc-notes-api |
ap-south-1 |
none |
| 4 | S3 bucket hoc-site-ab-4821 |
ap-south-1 |
project = Hands-On-Cloud |
| 5 | SNS topic hoc-alerts |
ap-south-1 |
project = hands-on-cloud |
| 6 | Lambda function hoc-test |
us-east-1 |
project = hands-on-cloud |
| 7 | S3 bucket hoc-logs-ab-4821 |
ap-south-1 |
project = hands-on-cloud, owner = ab |
Submit the row numbers of exactly the resources the search returns, separated by commas, in any order.
Sources
Each page below was fetched on 2026-10-02.
- MDN, Element: innerHTML property — https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML (fetched 2026-10-02)
- MDN, Node: textContent property — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent (fetched 2026-10-02)
- Types of metrics for Lambda functions — https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html (fetched 2026-10-02)
- Configuring how CloudWatch alarms treat missing data — https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/alarms-and-missing-data.html (fetched 2026-10-02)
- AWS CLI
cloudwatch put-metric-alarm— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/put-metric-alarm.html (fetched 2026-10-02) - AWS CLI
cloudwatch set-alarm-state— https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/set-alarm-state.html (fetched 2026-10-02) - Amazon SNS email subscription setup and management — https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html (fetched 2026-10-02)
- Finding resources to tag (Tag Editor) — https://docs.aws.amazon.com/tag-editor/latest/userguide/find-resources-to-tag.html (fetched 2026-10-02)
- Deleting a general purpose bucket — https://docs.aws.amazon.com/AmazonS3/latest/userguide/delete-bucket.html (fetched 2026-10-02)
- AWS CLI
iam delete-role— https://docs.aws.amazon.com/cli/latest/reference/iam/delete-role.html (fetched 2026-10-02) - Lambda API
GetFunction— https://docs.aws.amazon.com/lambda/latest/api/API_GetFunction.html (fetched 2026-10-02) - Explore AWS services with AWS Free Tier — https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/free-tier.html (fetched 2026-10-02)
TechEazy Consulting training material — not affiliated with or endorsed by Amazon Web Services (AWS).