A two-tier web application deployed on Google Cloud, built as a cloud security technical exercise. The environment is deliberately misconfigured in several places so the weaknesses can be found, demonstrated and discussed. Do not use it as a template for anything real.
- App: Tasky, a Go to-do app with JWT login, containerised and running on GKE
- Database: MongoDB 4.4 on a Compute Engine VM, with a nightly backup to Cloud Storage
- Infrastructure: Terraform, with state in a GCS bucket
- Delivery: GitHub Actions pipelines for infrastructure and for the app
Internet
│
┌─────────────┴───────────────┐
│ HTTP :80 │ SSH :22 (0.0.0.0/0)
▼ ▼
┌───────────────────┐ ┌────────────────────┐
│ GKE LoadBalancer │ │ mongodb-vm │
│ tasky-service │ │ Ubuntu 20.04 │
└─────────┬─────────┘ │ MongoDB 4.4 │──► gs://<backup bucket>
▼ │ public IP │ (daily mongodump, 01:00)
┌───────────────────┐ :27017 │ public subnet │
│ tasky pod │────────►│ 10.10.10.0/24 │
│ private subnet │ └────────────────────┘
│ 10.20.0.0/24 │
└───────────────────┘
egress via Cloud NAT
All resources live in one VPC in europe-west1.
| Component | Where it's defined | Notes |
|---|---|---|
| VPC, public + private subnets, Cloud Router/NAT, firewall rules | terraform/network.tf |
|
| GKE cluster + 1-node pool (e2-medium) | terraform/gke.tf |
Private nodes, public control-plane endpoint |
MongoDB VM mongodb-vm + its service account |
terraform/compute.tf |
Bootstrapped by terraform/startup-scripts/mongodb.sh |
Artifact Registry repo tasky-repo |
terraform/artifact_registry.tf |
Docker images for Tasky |
| Cluster-admin grant for the deployer SA | terraform/iam.tf |
|
| Tasky Deployment, LoadBalancer Service, Secret, ServiceAccount, ClusterRoleBinding | k8s/tasky/ |
|
| Tasky source + Dockerfile | tasky/ |
These are on purpose; several are flagged in comments in the code.
| Area | Weakness | Location |
|---|---|---|
| Network | SSH open to 0.0.0.0/0 on the DB VM |
network.tf |
| Compute | DB VM has a public IP | compute.tf |
| Compute | Outdated OS (Ubuntu 20.04) and database (MongoDB 4.4) | compute.tf, mongodb.sh |
| IAM | DB VM service account holds roles/editor on the project, with cloud-platform scope |
compute.tf |
| Database | MongoDB bound to 0.0.0.0; hard-coded admin and app credentials |
mongodb.sh |
| Kubernetes | Tasky's service account is bound to cluster-admin |
k8s/tasky/cluster-role-binding.yaml |
| Kubernetes | Secret committed to the repo in plain text (DB URI, JWT key) | k8s/tasky/secret.yaml |
| Container | Old base images (golang:1.19, alpine:3.17.0) |
tasky/Dockerfile |
| CI/CD | Long-lived service-account JSON key used for GCP auth | .github/workflows/*.yml |
- A GCP project with billing enabled and the Compute, Kubernetes Engine and Artifact Registry APIs turned on
- A service account named
terraform-deployerwith rights to create the resources above, and a JSON key for it - A GCS bucket for Terraform state, named in the backend block of
terraform/main.tf - A GCS bucket for MongoDB backups. It isn't created by Terraform: create it yourself, give the MongoDB VM's service account write access, and set
BUCKET_NAMEinterraform/startup-scripts/mongodb.sh - Terraform ≥ 1.5 (CI uses 1.6.6),
gcloud,kubectland Docker for local work
cd terraform
cp /path/to/key.json terraform-deployer-key.json # git-ignored; picked up by the provider if present
terraform init
terraform apply -var="project_id=<your-project-id>"Variables (all in variables.tf) default to europe-west1 / europe-west1-b and the CIDRs shown above; only project_id is required.
The VM's startup script installs MongoDB, creates an admin user and the tasky_user app user on the go-mongodb database, enables auth, and installs a cron job that runs /usr/local/bin/mongodb-backup.sh at 01:00 daily.
- Point
MONGODB_URIink8s/tasky/secret.yamlat the MongoDB VM's internal IP (currently10.10.10.21). - Update the image in
k8s/tasky/deployment.yamlto your project's registry path. - Build, push and apply:
REGION=europe-west1
IMAGE=$REGION-docker.pkg.dev/<project-id>/tasky-repo/<image-name>:latest
gcloud auth configure-docker $REGION-docker.pkg.dev
docker build -t $IMAGE ./tasky && docker push $IMAGE
gcloud container clusters get-credentials <cluster-name> --zone europe-west1-b
kubectl apply -f k8s/tasky/
kubectl get svc tasky-service # EXTERNAL-IP is the app URL, port 80Both workflows run on push and authenticate with the same service-account key.
terraform.yml — triggered by changes under terraform/. Runs init and plan on every branch; runs apply -auto-approve only on main.
tasky.yml — triggered by changes under tasky/ or k8s/tasky/. Builds the image tagged with the commit SHA, pushes it to Artifact Registry, applies the manifests, restarts the deployment, sets the new image and waits for rollout.
Repository secrets used:
| Secret | Used by | Purpose |
|---|---|---|
GCP_SA_KEY |
both | Service-account JSON key |
GCP_PROJECT |
both | Project ID (TF_VAR_project_id in Terraform) |
GCP_REGION |
tasky | Artifact Registry region, e.g. europe-west1 |
GCP_ARTIFACT_REPO |
tasky | Registry repo name, e.g. tasky-repo |
GKE_CLUSTER |
tasky | GKE cluster name |
GKE_ZONE |
tasky | europe-west1-b |
cd tasky
cat > .env <<EOF
MONGODB_URI=mongodb://localhost:27017
SECRET_KEY=change-me
EOF
go mod tidy
go run main.go # http://localhost:8080See tasky/README.md for more on the app itself.
# Pod's service account has cluster-admin
kubectl auth can-i '*' '*' --as=system:serviceaccount:default:tasky-service-account
# Backups are landing in the bucket
gsutil ls gs://<backup-bucket>/kubectl delete -f k8s/tasky/ # removes the LoadBalancer first
cd terraform && terraform destroy -var="project_id=<your-project-id>"The state bucket and backup bucket are outside Terraform and need deleting by hand.