10 · Capstone Project¶
Time to combine everything from this level into one small but real end-to-end application: a static frontend on Cloud Storage, a Cloud Run API backend, and a Cloud SQL database, wired together with the IAM, networking, and monitoring practices from earlier modules.
Read the teardown section before you start
This project uses billable resources (a Cloud SQL instance in particular). Jump to Teardown now and skim it, so you know exactly what you'll need to delete before you begin — don't let this be the one project you forget to clean up.
What you're building¶
Browser
│
▼
Cloud Storage (static site: index.html + JS)
│ fetch("https://<cloud-run-url>/api/notes")
▼
Cloud Run (Flask API: "notes" app)
│ Cloud SQL Auth Proxy / Unix socket
▼
Cloud SQL (PostgreSQL: notes table)
A minimal "notes" app: the frontend lists notes and lets you add one, the backend is a small REST API, the database persists the notes.
Step 1 — Database¶
gcloud sql instances create capstone-db \
--database-version=POSTGRES_15 \
--tier=db-f1-micro \
--region=us-central1
gcloud sql databases create notesdb --instance=capstone-db
gcloud sql users create notesapp \
--instance=capstone-db \
--password=CHANGE-ME-DEMO-ONLY
-- Run once via `gcloud sql connect capstone-db --user=postgres`, then \c notesdb
CREATE TABLE notes (
id SERIAL PRIMARY KEY,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
Step 2 — Backend API (Cloud Run)¶
# app.py
import os
from flask import Flask, jsonify, request
import psycopg2
from psycopg2.extras import RealDictCursor
app = Flask(__name__)
def get_conn():
return psycopg2.connect(
host=f"/cloudsql/{os.environ['INSTANCE_CONNECTION_NAME']}",
dbname=os.environ["DB_NAME"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
)
@app.route("/api/notes", methods=["GET"])
def list_notes():
with get_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT id, body, created_at FROM notes ORDER BY id DESC")
return jsonify(cur.fetchall())
@app.route("/api/notes", methods=["POST"])
def add_note():
body = request.get_json(force=True).get("body", "").strip()
if not body:
return jsonify({"error": "body is required"}), 400
with get_conn() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"INSERT INTO notes (body) VALUES (%s) RETURNING id, body, created_at",
(body,),
)
conn.commit()
return jsonify(cur.fetchone()), 201
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
Deploy, wiring in the Cloud SQL connection and (for this demo) plain environment variables — in anything beyond a learning capstone, pull the password from Secret Manager instead:
gcloud run deploy capstone-api \
--source=. \
--region=us-central1 \
--allow-unauthenticated \
--add-cloudsql-instances=PROJECT_ID:us-central1:capstone-db \
--set-env-vars=INSTANCE_CONNECTION_NAME=PROJECT_ID:us-central1:capstone-db,DB_NAME=notesdb,DB_USER=notesapp,DB_PASSWORD=CHANGE-ME-DEMO-ONLY
API_URL=$(gcloud run services describe capstone-api --region=us-central1 --format='value(status.url)')
curl -X POST "$API_URL/api/notes" -H "Content-Type: application/json" -d '{"body":"first note"}'
curl "$API_URL/api/notes"
Step 3 — Frontend (Cloud Storage static site)¶
<!-- index.html -->
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Capstone Notes</title></head>
<body>
<h1>Notes</h1>
<form id="form"><input id="body" placeholder="New note"><button>Add</button></form>
<ul id="list"></ul>
<script>
const API_URL = "REPLACE_WITH_YOUR_CLOUD_RUN_URL";
async function load() {
const res = await fetch(`${API_URL}/api/notes`);
const notes = await res.json();
document.getElementById("list").innerHTML =
notes.map(n => `<li>${n.body}</li>`).join("");
}
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const body = document.getElementById("body").value;
await fetch(`${API_URL}/api/notes`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({body}),
});
document.getElementById("body").value = "";
load();
});
load();
</script>
</body>
</html>
Replace REPLACE_WITH_YOUR_CLOUD_RUN_URL with the $API_URL value from
Step 2, then publish it:
gcloud storage buckets create gs://capstone-notes-frontend-PROJECT_ID \
--location=us-central1 \
--uniform-bucket-level-access
gcloud storage cp index.html gs://capstone-notes-frontend-PROJECT_ID/
gcloud storage buckets add-iam-policy-binding gs://capstone-notes-frontend-PROJECT_ID \
--member=allUsers \
--role=roles/storage.objectViewer
gcloud storage buckets update gs://capstone-notes-frontend-PROJECT_ID \
--web-main-page-suffix=index.html
The API needs to accept requests from the bucket's origin — for a learning
capstone, add a permissive CORS header in the Flask app
(response.headers["Access-Control-Allow-Origin"] = "*" on every response);
in production you'd restrict this to the exact frontend origin.
Visit https://storage.googleapis.com/capstone-notes-frontend-PROJECT_ID/index.html,
add a note, and confirm it round-trips through Cloud Run into Cloud SQL and
back.
Step 4 — Observe it¶
Reuse Module 8's habits on your own project:
gcloud logging read \
'resource.type="cloud_run_revision" AND resource.labels.service_name="capstone-api"' \
--limit=10
gcloud monitoring time-series list \
--filter='metric.type="run.googleapis.com/request_count" AND resource.labels.service_name="capstone-api"' \
--interval-start-time="$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)" \
--interval-end-time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
Teardown¶
This is not optional. Cloud SQL instances in particular bill continuously while they exist, free trial credit or not — delete every piece, in this order:
# 1. Cloud Run service
gcloud run services delete capstone-api --region=us-central1 --quiet
# 2. Frontend bucket and its contents
gcloud storage rm --recursive gs://capstone-notes-frontend-PROJECT_ID
gcloud storage buckets delete gs://capstone-notes-frontend-PROJECT_ID --quiet
# 3. Cloud SQL instance -- the highest-cost resource in this project
gcloud sql instances delete capstone-db --quiet
# 4. Confirm nothing capstone-related is still running
gcloud run services list
gcloud sql instances list
gcloud storage buckets list --filter="name:capstone"
gcloud compute instances list
The last four commands should all come back empty (or show only
resources from earlier modules that you've separately confirmed you still
want). If gcloud sql instances list shows anything, billing for it
continues — don't stop at step 3 without verifying with step 4.
Cheat sheet¶
| Command | Purpose |
|---|---|
gcloud sql instances create/delete |
Provision/remove the database — highest ongoing cost, delete first priority. |
gcloud run deploy --source=. --add-cloudsql-instances= |
Deploy the API wired to Cloud SQL. |
gcloud storage buckets create --uniform-bucket-level-access |
Create the frontend bucket. |
gcloud storage buckets update --web-main-page-suffix= |
Enable static website serving. |
gcloud run services delete |
Tear down the backend. |
gcloud storage buckets delete |
Tear down the frontend bucket (must be empty first). |
gcloud sql instances list / gcloud run services list |
Verify teardown left nothing running. |
How It Actually Works¶
Stitching VPC, Compute Engine, Cloud SQL, and IAM together in one project exposes the layering you've been building module by module: the VPC's private subnet range and firewall rules are enforced by the hypervisor per-packet before a request ever reaches your VM's kernel; the VM's service account (not your personal identity) is what Cloud SQL's IAM authentication actually checks when the app connects via the Cloud SQL Auth Proxy, which works by opening a local Unix socket or TCP port and tunnelling traffic through an encrypted, IAM-authorized connection to the instance's private IP — your application code never needs the database password or a public IP path. When you tear the stack down, dependency order matters for the same reason Terraform computes a graph: Cloud SQL must be deleted before the VPC it's peered into, and firewall rules attached to the network must go before the network itself, because each resource's existence is a precondition the next one's API call checks at delete time, not just at create time.
Exercise¶
Extend the notes app with a DELETE /api/notes/<id> endpoint and a delete
button per note in the frontend. Redeploy both pieces, verify delete works
end-to-end from the browser, and then — before you consider this capstone
done — run every command in the Teardown section and confirm all four
verification commands come back empty.