← Back to list

How I Designed a Secure Two-Tier Architecture on GCP (and What Broke Along the Way)

A field note from building FreshCart’s backend infrastructure — a grocery delivery app that doesn’t exist yet, on real cloud architecture.

Bezaleel · 2026-07-30 16:33 · 0 claps · 9.3 min read
#google-cloud-platform #two-tier-architecture #cloud-engineering
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud 🏛️ · Architecture

How I Designed a Secure Two-Tier Architecture on GCP (and What Broke Along the Way)

A field note from building FreshCart’s backend infrastructure — a grocery delivery app that doesn’t exist yet, on real cloud architecture.

Intro

FreshCart is a hypothetical grocery delivery app, a two tier application built on a single VM to serve customers in the early stage of the product. In this article, I take on the role of FreshCart’s newly hired Cloud Engineer, here to help build the infra that helps them go from 100 concurrent users to 4000.

The problem with a single VM

The fastest way to get FreshCart’s backend online is a single VM, a public IP, and port 80 open. It would work. It would also mean that one machine is simultaneously your compute, your public attack surface, and your single point of failure — three jobs that have no business being done by the same box.

If that VM goes down, FreshCart goes down. If it gets compromised, whoever’s inside has a direct line to whatever else you bolt on later — a database, a cache, internal tooling — because nothing was ever designed to keep them apart. And there’s no way to grow past it without re-architecting under pressure, usually during the traffic spike that made the growth necessary in the first place.

So I built FreshCart’s backend as a two-tier architecture instead: a private compute layer that the internet never touches directly, fronted by a load balancer that’s the only thing actually exposed. Everything below is how that came together, entirely from the CLI, and the two decisions in the middle that turned out to be harder than they looked on paper.

The architecture

FreshCart’s infrastructure splits into two independent paths that both live inside one VPC, but expose themselves to the public internet in completely different ways.

The backend path starts with an external HTTP load balancer as the only public entry point. It health-checks a backend service, which — after a mid-build change I’ll get into — fans out to two instances of a placeholder nginx server (standing in for FreshCart’s actual app, since the point of this build is the network around the app, not the app itself). Both instances live in private subnets with no public IP assigned to either one. Getting out to the internet for OS updates and package installs happens through Cloud NAT, which is explicitly scoped to only the subnets that need it — nothing broader.

The static site path is a completely separate load balancer, this one terminating HTTPS with a Google-managed certificate, sitting in front of a Cloud Storage bucket that serves FreshCart’s “coming soon” landing page. It has its own public IP, its own certificate, and its own custom domain — deliberately decoupled from the backend path, since a compute-serving load balancer and a bucket-serving one prove genuinely different things.

A few specific choices are doing real work here, not just filling out a checklist:

--no-address

This one flag is the actual guarantee that the backend is unreachable except through the load balancer. Everything else — subnet placement, firewall rules — reinforces it, but this is the line that would break “private” if it were missing.

--source-ranges=130.211.0.0/22,35.191.0.0/16

The firewall rule that lets traffic reach the backend doesn’t open the door to the internet — it opens it specifically to Google’s documented load balancer and health-check ranges. Everything else stays denied by default, which is the actual point of putting a VM in a private subnet in the first place.

A lifecycle rule on the storage bucket moves objects to a cheaper storage tier after 30 days and deletes them after 90 — more on why that one required more thought than it should have, below.

[Insert your draw.io architecture diagram here — two lanes, one shared VPC, public/private boundary clearly marked]

The two hardest decisions

1. Adding real redundancy

I decided FreshCart’s backend shouldn’t depend on a single VM staying healthy. The fix seemed simple: a second private subnet, a second VM in a different zone, added as a second backend to the same load balancer.

Creating that second VM immediately failed with ZONE_RESOURCE_POOL_EXHAUSTED. That's not a misconfiguration — it's Google telling you, plainly, that the specific machine type you asked for doesn't have capacity in that specific zone right now. I tried other zones in the same region. Same error. I tried a smaller machine type. Same error, in every zone I tried.

The actual fix was switching the second VM to spot provisioning:

--provisioning-model=SPOT \
--instance-termination-action=STOP

Spot capacity draws from a separate pool than on-demand instances, which is exactly why it worked when nothing else did. The honest tradeoff: Google can reclaim that capacity with about 30 seconds’ notice, and --instance-termination-action=STOP means the VM stops rather than getting deleted outright — the disk and config survive, and it's one command to bring back. For a demo VM under active development, that's a complete non-issue. It would be a very different conversation for something serving real traffic.

What actually made this worth doing: adding the second VM only required touching few parts of the build stages. The load balancer’s health check, URL map, and forwarding rule never changed — they’re attached to the backend service, which now just fans out to two backends instead of one. If either zone has a problem, the health check stops routing to it and everything shifts to the survivor automatically.

2. The lifecycle rule that could delete my own live site

I had a simple requirement for the lifecycle rules: objects older than 30 days move to a cheaper storage tier (Nearline), and anything older than 90 days gets deleted. That was easy to implement as a JSON config:

{
  "rule": [
    {
      "action": {"type": "SetStorageClass", "storageClass": "NEARLINE"},
      "condition": {"age": 30}
    },
    {
      "action": {"type": "Delete"},
      "condition": {"age": 90}
    }
  ]
}

Problem is this rule applies to the entire bucket — and that same bucket is serving the live index.html for FreshCart's static site. Taken literally, day 90 doesn't just clean up old data. It deletes the file the site depends on to exist.

I had two real options: scope the rule to a subpath (something like an archive/ prefix) so it only ever touches content that's genuinely meant to age out, or apply it bucket-wide and accept that it's the easy way out while being wrong for a bucket that also serves live content.

I went with applying it bucket-wide, for a specific reason: trying to manage my GCP credits, the project will be get torn down within days, nowhere near the 30- or 90-day thresholds, so the conflict never actually matters here. But I don’t think that’s a reason to pretend the conflict doesn’t exist. If FreshCart’s static site were real and long-lived, I’d split this into two buckets — one serving live content with no lifecycle rule at all, and a separate one for anything that’s actually meant to age out.

What I’d add next

The obvious next step for a system like this — a second backend instance for redundancy — is already done. Asides that, here’s what a real, growing FreshCart would need that this capstone deliberately doesn’t have yet:

  • Managed instance groups instead of unmanaged ones. Right now, adding a third backend means manually creating another instance group and VM. A managed instance group with autoscaling would make that a policy, not a manual step.
  • Cloud CDN in front of the static site. The backend bucket supports it with one flag. I left it off deliberately, since it’s a clean answer to “what’s next” rather than something to enable before there’s any traffic to justify it.
  • Real monitoring and alerting, beyond Cloud NAT’s connection logging — uptime checks on both load balancers and alerting policies that fire before a customer notices, not after.
  • Multi-region, not just multi-zone, for the backend. Two zones protect against a zone-level outage. A regional outage would still take the whole thing down.

Commands Used to Build FreshCart Infra

I put all the commands and the order they were ran in a script, although I didnt. Here’s all the commands I used:

#!/usr/bin/env bash

# FreshCart — Two-Tier GCP Architecture, full build 

# The teardown script is intentionally NOT included, it's in a separate script.
#
# Fill in every variable below before running.

set -euo pipefail

# VARIABLES

PROJECT_ID="your-project-id"
REGION="us-central1"
ZONE="us-central1-a"          
ZONE_B="us-central1-b"        
BILLING_ACCOUNT_ID="XXXXXX-XXXXXX-XXXXXX"

DUCKDNS_SUBDOMAIN="yourname"          
DUCKDNS_TOKEN="yourname-duckdns-token"
DOMAIN="${DUCKDNS_SUBDOMAIN}.duckdns.org"

BUCKET_NAME="freshcart-static-${PROJECT_ID}"
HTML_FILE="$HOME/Downloads/freshcart-coming-soon.html"   

echo "== FreshCart build starting: $PROJECT_ID =="

#PROJECT SETUP

gcloud config set project "$PROJECT_ID"
gcloud config set compute/region "$REGION"
gcloud services enable compute.googleapis.com storage.googleapis.com

gcloud billing budgets create \
  --billing-account="$BILLING_ACCOUNT_ID" \
  --display-name="FreshCart Capstone Budget" \
  --budget-amount=10USD \
  --threshold-rule=percent=0.5 \
  --threshold-rule=percent=0.9 \
  --threshold-rule=percent=1.0 

#CREATE VPC AND TWO PRIVATE SUBNETS

gcloud compute networks create freshcart-vpc --subnet-mode=custom

gcloud compute networks subnets create freshcart-private-subnet \
  --network=freshcart-vpc \
  --region="$REGION" \
  --range=10.0.2.0/24 \
  --enable-private-ip-google-access

gcloud compute networks subnets create freshcart-private-subnet-2 \
  --network=freshcart-vpc \
  --region="$REGION" \
  --range=10.0.3.0/24 \
  --enable-private-ip-google-access

#CREATE FIREWALL RULES

gcloud compute firewall-rules create allow-lb-health-check \
  --network=freshcart-vpc \
  --direction=INGRESS \
  --action=ALLOW \
  --rules=tcp:8080 \
  --source-ranges=130.211.0.0/22,35.191.0.0/16 \
  --target-tags=backend

gcloud compute firewall-rules create allow-iap-ssh \
  --network=freshcart-vpc \
  --direction=INGRESS \
  --action=ALLOW \
  --rules=tcp:22 \
  --source-ranges=35.235.240.0/20 \
  --target-tags=backend

#CLOUD ROUTER & CLOUD NAT

gcloud compute routers create freshcart-router \
  --network=freshcart-vpc \
  --region="$REGION"

gcloud compute routers nats create freshcart-nat \
  --router=freshcart-router \
  --region="$REGION" \
  --nat-custom-subnet-ip-ranges=freshcart-private-subnet,freshcart-private-subnet-2 \
  --auto-allocate-nat-external-ips \
  --enable-logging

#CREATE TWO BACKEND VMS IN TWO SEPERATE SUBNETS

gcloud iam service-accounts create freshcart-backend-sa \
  --display-name="FreshCart backend VM"

cat > /tmp/freshcart-startup.sh << 'EOF'
#!/bin/bash
apt-get update
apt-get install -y nginx
sed -i 's/listen 80 default_server;/listen 8080 default_server;/' /etc/nginx/sites-available/default
sed -i 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' /etc/nginx/sites-available/default
echo "FreshCart backend is up" > /var/www/html/index.nginx-debian.html
systemctl restart nginx
systemctl enable nginx
EOF

gcloud compute instances create freshcart-backend-vm \
  --zone="$ZONE" \
  --machine-type=e2-micro \
  --image-family=debian-12 \
  --image-project=debian-cloud \
  --boot-disk-size=10GB \
  --subnet=freshcart-private-subnet \
  --no-address \
  --tags=backend \
  --service-account="freshcart-backend-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
  --scopes=https://www.googleapis.com/auth/logging.write,https://www.googleapis.com/auth/monitoring.write \
  --metadata-from-file=startup-script=/tmp/freshcart-startup.sh

gcloud compute instances create freshcart-backend-vm-2 \
  --zone="$ZONE_B" \
  --machine-type=e2-micro \
  --image-family=debian-12 \
  --image-project=debian-cloud \
  --boot-disk-size=10GB \
  --subnet=freshcart-private-subnet-2 \
  --no-address \
  --tags=backend \
  --service-account="freshcart-backend-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
  --scopes=https://www.googleapis.com/auth/logging.write,https://www.googleapis.com/auth/monitoring.write \
  --metadata-from-file=startup-script=/tmp/freshcart-startup.sh \
  --provisioning-model=SPOT \
  --instance-termination-action=STOP

echo "== Waiting 60s for both VMs to finish their startup script =="
sleep 60

for vm in "freshcart-backend-vm:$ZONE" "freshcart-backend-vm-2:$ZONE_B"; do
  name="${vm%%:*}"
  z="${vm##*:}"
  gcloud compute scp "$HTML_FILE" "${name}:/tmp/index.html" --zone="$z" --tunnel-through-iap
  gcloud compute ssh "$name" --zone="$z" --tunnel-through-iap --command="
    sudo mv /tmp/index.html /var/www/html/index.html &&
    sudo rm -f /var/www/html/index.nginx-debian.html &&
    sudo systemctl reload nginx
  "
done

#LOAD BALANCER AND HEALTH CHECK

gcloud compute addresses create freshcart-lb-ip --ip-version=IPV4 --global

gcloud compute health-checks create http freshcart-health-check \
  --port=8080 \
  --request-path=/ \
  --check-interval=10s \
  --timeout=5s \
  --healthy-threshold=2 \
  --unhealthy-threshold=3

gcloud compute instance-groups unmanaged create freshcart-backend-group --zone="$ZONE"
gcloud compute instance-groups unmanaged add-instances freshcart-backend-group --zone="$ZONE" --instances=freshcart-backend-vm
gcloud compute instance-groups unmanaged set-named-ports freshcart-backend-group --zone="$ZONE" --named-ports=http:8080

gcloud compute instance-groups unmanaged create freshcart-backend-group-2 --zone="$ZONE_B"
gcloud compute instance-groups unmanaged add-instances freshcart-backend-group-2 --zone="$ZONE_B" --instances=freshcart-backend-vm-2
gcloud compute instance-groups unmanaged set-named-ports freshcart-backend-group-2 --zone="$ZONE_B" --named-ports=http:8080

gcloud compute backend-services create freshcart-backend-service \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --protocol=HTTP \
  --port-name=http \
  --health-checks=freshcart-health-check \
  --global

gcloud compute backend-services add-backend freshcart-backend-service \
  --instance-group=freshcart-backend-group \
  --instance-group-zone="$ZONE" \
  --global

gcloud compute backend-services add-backend freshcart-backend-service \
  --instance-group=freshcart-backend-group-2 \
  --instance-group-zone="$ZONE_B" \
  --global

gcloud compute url-maps create freshcart-url-map \
  --default-service=freshcart-backend-service \
  --global

gcloud compute target-http-proxies create freshcart-http-proxy \
  --url-map=freshcart-url-map

gcloud compute forwarding-rules create freshcart-http-forwarding-rule \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --address=freshcart-lb-ip \
  --global \
  --target-http-proxy=freshcart-http-proxy \
  --ports=80

#GCS static site + DuckDNS + managed cert

gcloud storage buckets create "gs://$BUCKET_NAME" \
  --location="$REGION" \
  --uniform-bucket-level-access

gcloud storage buckets update "gs://$BUCKET_NAME" \
  --web-main-page-suffix=index.html \
  --web-error-page=index.html

gcloud storage cp "$HTML_FILE" "gs://$BUCKET_NAME/index.html"

gcloud storage buckets add-iam-policy-binding "gs://$BUCKET_NAME" \
  --member=allUsers \
  --role=roles/storage.objectViewer

gcloud compute addresses create freshcart-static-site-ip --ip-version=IPV4 --global

STATIC_IP=$(gcloud compute addresses describe freshcart-static-site-ip --global --format="get(address)")
echo "Static site IP: $STATIC_IP — updating DuckDNS now"
curl -s "https://www.duckdns.org/update?domains=${DUCKDNS_SUBDOMAIN}&token=${DUCKDNS_TOKEN}&ip=${STATIC_IP}"

gcloud compute backend-buckets create freshcart-static-backend \
  --gcs-bucket-name="$BUCKET_NAME"

gcloud compute ssl-certificates create freshcart-static-cert \
  --domains="$DOMAIN" \
  --global

gcloud compute url-maps create freshcart-static-url-map \
  --default-backend-bucket=freshcart-static-backend \
  --global

gcloud compute target-https-proxies create freshcart-static-https-proxy \
  --url-map=freshcart-static-url-map \
  --ssl-certificates=freshcart-static-cert

gcloud compute forwarding-rules create freshcart-static-https-rule \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --address=freshcart-static-site-ip \
  --global \
  --target-https-proxy=freshcart-static-https-proxy \
  --ports=443

#LIFECYCLE RULE

cat > /tmp/freshcart-lifecycle.json << 'EOF'
{
  "rule": [
    {
      "action": {"type": "SetStorageClass", "storageClass": "NEARLINE"},
      "condition": {"age": 30}
    },
    {
      "action": {"type": "Delete"},
      "condition": {"age": 90}
    }
  ]
}
EOF

gcloud storage buckets update "gs://$BUCKET_NAME" --lifecycle-file=/tmp/freshcart-lifecycle.json

Closing

Everything here was built and torn down through the GCP CLI— no console clicks, start to finish. That’s a genuinely different way to work than clicking through the GCP console to create and manage every resource: every decision above is legible in a command, not buried in a UI state that’s hard to reconstruct later.


메타데이터
post_id
acd8c0a13e1a
slug
how-i-designed-a-secure-two-tier-architecture-on-gcp-and-what-broke-along-the-way-acd8c0a13e1a
url
https://medium.com/@b3zaleel_3512/how-i-designed-a-secure-two-tier-architecture-on-gcp-and-what-broke-along-the-way-acd8c0a13e1a
canonical_url
https://medium.com/@b3zaleel_3512/how-i-designed-a-secure-two-tier-architecture-on-gcp-and-what-broke-along-the-way-acd8c0a13e1a
author_url
https://medium.com/@b3zaleel_3512
status
ok
fetched_at
2026-08-23 12:51:04