Deploying OWASP Juice Shop on GCP (and everything that went sideways along the way)

Posted on | 1908 words | ~9mins
work

I needed a throwaway instance of OWASP Juice Shop for some testing recently - nothing fancy, just the intentionally-vulnerable app running somewhere I control, locked down to a handful of IPs, with a small tweak to the homepage so I could prove ownership of it. Simple enough on paper. In practice, it turned into a decent tour of where GCP’s serverless and networking primitives quietly stop talking to each other. Writing this up mostly for future-me, but maybe it saves someone else an afternoon.

The ask, stripped down

Three requirements, really:

  1. Run Juice Shop on GCP, with a small modification to the homepage.
  2. Restrict access to a specific set of IPs - this is a deliberately vulnerable app, so “public internet” was never on the table.
  3. Be able to change that homepage modification after the app was already live, since it was going to be used for domain/ownership verification, and I wouldn’t know the exact value until I had a URL to submit somewhere.

That last point ended up mattering more than I expected, and reshaped the whole approach.

First instinct: bake it into the HTML

My first pass was the obvious one - clone the Juice Shop repo, drop a <meta> tag into frontend/src/index.html, build the image, ship it. Textbook.

Except the moment I actually thought about requirement #3, this fell apart. If the verification value is only known after deployment, baking it into the HTML at build time means a full rebuild-and-redeploy cycle every time it changes. Fine once, annoying forever.

The fix was to stop treating it as a meta tag at all. Verification schemes (Google Search Console and friends) usually support a file-based method too - drop a specific file at a specific path, with specific content, at the webroot. So instead of editing HTML, I added a tiny bit of middleware to Juice Shop’s server.ts that reads the expected filename and content from environment variables and serves them dynamically:

app.get('/:verificationFile', (req, res, next) => {
  const expectedFilename = process.env.VERIFICATION_FILENAME
  if (expectedFilename && req.params.verificationFile === expectedFilename) {
    res.type('text/html').send(process.env.VERIFICATION_CONTENT || '')
    return
  }
  next()
})

Now changing the verification value later is just an environment variable update, not a rebuild. Small change, but it’s the kind of thing that’s obvious in hindsight and easy to miss in the moment if you’re anchored on “meta tag” as the literal requirement instead of the actual goal.

Building without touching my laptop

Since I wanted the whole build to happen inside GCP rather than locally, Cloud Shell plus Cloud Build was the right combination - clone the repo, edit server.ts in the Cloud Shell editor, then:

gcloud builds submit --tag europe-west2-docker.pkg.dev/$PROJECT_ID/juice-shop-repo/juice-shop-custom:latest

This is where I hit the first of several org-policy walls that ended up shaping most of the rest of this project. gcloud builds submit auto-creates a staging bucket for your source, and it defaults to the US multi-region - which my org’s gcp.resourceLocations policy rejected outright, since everything here needed to stay in the EU. The fix was to create the staging bucket myself, explicitly in europe-west2, and point the build at it:

gcloud storage buckets create gs://${PROJECT_ID}-cloudbuild-staging \
  --location=europe-west2

gcloud builds submit \
  --region=europe-west2 \
  --gcs-source-staging-dir=gs://${PROJECT_ID}-cloudbuild-staging/source \
  --tag europe-west2-docker.pkg.dev/$PROJECT_ID/juice-shop-repo/juice-shop-custom:latest

Worth remembering: specifying a region for the build itself doesn’t mean every implicit resource the build touches follows along. Staging buckets, log buckets - check them all if you’re under a resource-location policy.

The Cloud Run detour

With the image built, Cloud Run seemed like the natural home for it - serverless, cheap, and I liked the idea of not managing a VM for something this disposable. The plan was straightforward: deploy to Cloud Run, put a Global External Application Load Balancer in front of it via a Serverless NEG, and attach a Cloud Armor policy with an IP allowlist.

This is where things got genuinely interesting, in the way things get “interesting” when you’re troubleshooting instead of building.

First snag: my org blocks unauthenticated Cloud Run services outright - there’s no world where allUsers gets run.invoker here, full stop. Reasonable policy, but it meant Cloud Run had to run with “Require authentication” from the start, which pushed the whole plan onto whether the Load Balancer could actually authenticate itself to Cloud Run on my behalf.

It can’t. Or rather - it doesn’t, by default, and I spent longer than I’d like admitting confirming this than actually fixing it. I went down the path of granting run.invoker to the Compute Engine default service account, assuming that was the identity the Serverless NEG used to call through. Every request still came back 403 Forbidden, and the Cloud Run logs were blunt about why: “Empty Authorization header value.” The Load Balancer wasn’t attaching any identity token to the request at all.

Turns out this isn’t a config mistake, it’s just how External Application Load Balancers work. Per Google’s own docs, they don’t support authenticating end-user requests to Cloud Run - the only officially supported bridge for an IAM-protected Cloud Run service behind a public Load Balancer is Identity-Aware Proxy. IAP has its own service account, and that’s the identity you grant run.invoker to; IAP handles attaching a signed token to the forwarded request.

Which would’ve worked, except IAP’s whole model is per-user Google authentication - sign in with a Google account, get checked against IAM roles on the IAP resource. That’s a completely different access-control shape than “let three specific IPs in, no login prompt.” You can bolt IP-based conditions onto IAP via Access Context Manager and BeyondCorp-style policies, but that’s a lot of additional machinery for what should’ve been a simple allowlist on a test instance.

So: dead end, sort of. Not because anything was misconfigured, but because I’d picked an architecture (external LB → IAM-protected Cloud Run) that Google doesn’t actually support without IAP sitting in the middle, and IAP wasn’t the access model I wanted.

Back to basics: Compute Engine

The moment I stepped back, the fix was obvious - this is exactly the kind of situation classic VPC firewall rules were built for. No IAM bridging, no identity tokens, no proxy layer between the load balancer and the app. Just: a VM, a tag, and a firewall rule scoped to specific source IPs.

Tearing down the Cloud Run/Load Balancer stack was its own small lesson in humility - Console-created resources don’t always get the names you’d guess. The Load Balancer’s forwarding rule, target proxy, URL map, and backend service all had names slightly different from what I’d typed in as “the Load Balancer’s name,” so the clean way to tear things down was: list the resource type, confirm the actual name, delete, repeat - rather than trusting a naming convention.

gcloud compute forwarding-rules list --global
gcloud compute target-http-proxies list
gcloud compute url-maps list
gcloud compute backend-services list --global

Standard reverse-order deletion after that: forwarding rule → target proxy → URL map → backend service → NEG → Cloud Run service.

One more wrinkle worth flagging: the static IP I’d reserved for the Load Balancer was a global IP, which is required for that architecture - but Compute Engine VMs need a regional external IP, and a global reservation can’t just be reattached. Released the global one, reserved a fresh regional IP in europe-west2, and moved on.

Creating the VM itself surfaced one more org-policy artifact: there was no “default” VPC network in the project at all - presumably auto-creation of the default network is disabled at the org level, which is a sane security default I’ve just never had to think about before. Fix was simple enough, create a VPC explicitly:

gcloud compute networks create juice-shop-vpc --subnet-mode=auto
  • and then remember that a custom network doesn’t come with the usual default-allow-ssh rule that GCP auto-provisions for the actual default network. If you want SSH access later, that’s a separate rule you have to add yourself.

The part that actually worked cleanly

Once the VM was up with the container attached and the right static IP, the firewall rule itself was almost anticlimactic after everything before it:

gcloud compute firewall-rules create allow-juice-shop-restricted \
  --network=juice-shop-vpc \
  --direction=INGRESS \
  --action=ALLOW \
  --rules=tcp:3000 \
  --source-ranges=<allowed IPs>/32 \
  --target-tags=juice-shop \
  --priority=1000

Traffic in from the allowlisted IPs, nothing else. No IAM, no service accounts to reason about, no proxy layer with its own authentication model bolted on top. Exactly the shape of access control I’d wanted from the start.

And because the verification logic was already wired to environment variables rather than baked into the image, updating it once I had the real token was a non-event - either through the Console’s VM edit screen, or:

gcloud compute instances update-container juice-shop-vm \
  --zone=europe-west2-a \
  --container-env=VERIFICATION_FILENAME=real-file.html,VERIFICATION_CONTENT=real-value

No rebuild, no redeploy, container restarts with the new values in seconds.

One more snag: the verification URL needed to be port-80-clean

Everything above worked - right up until the party checking the verification file wanted to hit <STATIC_EXTERNAL_IP>/validation_file.txt with no port number in the URL. Fair enough, that’s the normal expectation for this kind of check, but the container itself only listens on 3000, and I hadn’t accounted for that gap.

Rather than touching the app to bind to port 80 directly (fiddlier than it sounds, since binding to ports below 1024 needs elevated privileges inside the container), the cleaner fix was to redirect incoming traffic at the host level with an iptables rule - anything hitting port 80 gets forwarded straight to 3000:

iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000

Baked that into the VM’s startup script so it survives a reboot, rather than relying on a one-off SSH session:

gcloud compute instances add-metadata juice-shop-vm \
  --zone=europe-west2-a \
  --metadata=startup-script='#!/bin/bash
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
'

Then just needed to open port 80 on the same firewall rule that was already scoped to my allowed IPs:

gcloud compute firewall-rules update allow-juice-shop-restricted \
  --rules=tcp:3000,tcp:80

After that, <STATIC_EXTERNAL_IP>/validation_file₹.txt returned a clean 200 with no port number needed - small fix, but a good reminder that “the app is reachable” and “the app is reachable at the URL someone else is going to actually type” aren’t always the same checkbox.

What I’d do differently

If I were starting this over knowing what I know now, I’d have gone straight to Compute Engine, given the requirement was IP-based access control on an app that has to stay authenticated at the platform layer. Cloud Run and its Load Balancer/Cloud Armor combination is a genuinely good pattern - it’s just built around IAP-style per-user auth for anything requiring authentication, not raw IP filtering. If your access model really is “specific IPs, no login,” Compute Engine’s firewall rules are just a more direct match for the job, org policies notwithstanding.

The org policies weren’t really obstacles in the end - resource locations, blocked default networks, blocked public Cloud Run invocation - they’re all reasonable defaults for an org that cares about where things run and who can reach them. They just meant I found out the hard way that “serverless + load balancer + IP allowlist” isn’t actually a supported combination on GCP without IAP in the mix, which was worth learning properly rather than assuming.

Total time from “let’s deploy this” to “locked down and working”: longer than it should’ve been, entirely because of one wrong architectural assumption about how load balancers and Cloud Run talk to each other. Worth the detour, mostly for the write-up.