How to Solve CAPTCHAs on Google Cloud Run Without a 504

A Cloud Run captcha solve can die in three places, and in two of them your code never sees an error. The Python buildpack starts your app with gunicorn’s defaults, and gunicorn kills a worker that stays busy for 30 seconds, which a reCAPTCHA solve often does. Cloud Run’s own request timeout defaults to 300 seconds, exactly the SDK’s reCAPTCHA polling timeout, so the 504 always wins that race. And 127.0.0.1 inside the container is the container. CapSkip runs on a Windows machine you own, and the service is only the client. Here is the deploy that clears all three.
What you need
- CapSkip running on a Windows machine you control. It is a desktop application and it does not run inside Cloud Run. The service calls it over HTTP, nothing more.
- Server mode switched on. Local mode answers on 127.0.0.1 for that device only, which is useless to a container in Google’s network. Server mode listens on your network address or public IP so the service can reach it over the same API, and both live under connection settings. A static public IP is recommended, with a firewall rule for the one address Google will arrive from.
- A Python service deployed from source, with a requirements.txt listing capskip, flask and gunicorn. The CapSkip package needs Python 3.10 or newer.
- The gcloud CLI, and a VPC network with a subnet in the service’s region for Step 3.
Why three timeouts decide a Cloud Run captcha deploy
Three clocks run over every solve, and on a default deploy the wrong one fires first.
| Clock | Default | What happens when it fires |
|---|---|---|
| gunicorn worker timeout, from the buildpack’s default entrypoint | 30 seconds | The worker is killed and restarted mid-solve, and the caller gets a server error |
| Cloud Run request timeout | 300 seconds, raisable to 3600 | The caller gets a 504, while the container carries on with the request |
| CapSkip reCAPTCHA polling timeout | 300 seconds | The client raises a TimeoutException your code can handle |
Start with gunicorn. For a Python source deploy, the buildpack’s default entrypoint is gunicorn bound to port 8080 with nothing else set, which means one worker, one thread, and gunicorn’s 30 second worker timeout. Gunicorn kills and restarts a worker that stays silent past that limit, and a sync worker waiting on one slow solve is silent. So a reCAPTCHA that takes 40 seconds never comes back.
Then the tie. Cloud Run closes the connection at 300 seconds and returns a 504, and Google’s docs point out that the instance is not terminated, so your code may keep processing a request nobody is waiting for. The SDK’s timeout is also 300 seconds, but its clock starts later, after the request has arrived and the job has been submitted. Cloud Run’s clock always fires first, and the TimeoutException that would have told you what happened never reaches the caller. Google’s request timeout guide says to set the limit above your expected execution time, and to check your framework’s own timeout too. Gunicorn is that framework timeout.
Step 1: write the service
A small Flask app with one route, saved as main.py. Build the client once, at import. It holds only its settings, so every thread in the worker can share it.
# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import CapSkip
app = Flask(__name__)
# The client does not read CAPSKIP_HOST by itself: pass it in.
solver = CapSkip(
host=os.environ["CAPSKIP_HOST"], # Server mode address
port=int(os.environ.get("CAPSKIP_PORT", "8080")),
apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
)
@app.post("/solve")
def solve():
job = request.get_json(force=True)
result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
return jsonify(token=result["code"]) # use it straight awayReading the host with a hard lookup rather than a default is deliberate. If the variable is missing, the import fails, gunicorn cannot boot a worker, and the new revision never starts serving. That is a much clearer failure than a revision that deploys cleanly and then throws a NetworkException against loopback on the first real request.
Step 2: deploy with the right entrypoint, timeout and concurrency
Every fix a Cloud Run captcha service needs from the table above goes in the deploy command, so none of it lives in code.
# Run from the folder holding main.py and requirements.txt gcloud run deploy solve-captcha \ --source . \ --region us-central1 \ --no-allow-unauthenticated \ --set-build-env-vars GOOGLE_ENTRYPOINT="gunicorn --bind :8080 --workers 1 --threads 8 --timeout 0 main:app" \ --timeout 400 \ --concurrency 8 \ --set-env-vars CAPSKIP_HOST=203.0.113.10,CAPSKIP_PORT=8080,CAPSKIP_API_KEY=YOUR_API_KEY
On Windows PowerShell, replace each trailing backslash with a backtick. Here is what each line changes:
- The entrypoint line is the gunicorn fix. A timeout of 0 switches gunicorn’s worker timeout off and leaves timing to Cloud Run, and eight threads let one instance run eight solves at once. These are the settings Google’s own buildpack docs use as their example, and gunicorn has to be listed in requirements.txt when you override the default. The port is written as 8080 rather than as the PORT variable because your own shell would expand that variable, to nothing, before gcloud ever saw it. Cloud Run sends traffic to 8080 unless you tell it otherwise.
- The request timeout of 400 seconds sits above the client’s 300 with room to spare. The client’s clock starts only once the job is submitted, and on a fresh instance behind Cloud NAT that first connection can take a minute.
- The concurrency line stops requests from queueing where Cloud Run cannot see them. A service deployed with gcloud accepts up to 80 concurrent requests per vCPU by default. With eight threads, requests nine to eighty queue inside gunicorn with Cloud Run’s clock already running, while the instance looks like it has room to spare. Matching concurrency to threads makes Cloud Run start another instance instead.
- The environment variables carry the Server mode address of your CapSkip machine and its API key, which main.py reads explicitly. Once this works, Secret Manager and the set-secrets flag are a tidier home for the key.
- The no-allow-unauthenticated line keeps the endpoint private, so only callers with permission to invoke the service can spend your solver’s time through it.
Step 3: give the service one static outbound IP
By default a Cloud Run service reaches the internet from a dynamic pool of Google addresses, so your firewall has no single IP to allow. The documented fix is to send the service’s egress through a VPC network with a Cloud NAT gateway that holds a reserved static address.
# Reserve one address and put Cloud NAT in front of the subnet gcloud compute routers create capskip-router \ --network default --region us-central1 gcloud compute addresses create capskip-egress --region us-central1 gcloud compute routers nats create capskip-nat \ --router capskip-router --region us-central1 \ --nat-custom-subnet-ip-ranges default \ --nat-external-ip-pool capskip-egress # Send ALL of the service's outbound traffic through that VPC gcloud run services update solve-captcha --region us-central1 \ --network default --subnet default --vpc-egress all-traffic
The last flag is the one people miss. The default egress setting is private-ranges-only, which sends only traffic for private addresses through the VPC. Your solver sits on a public IP, so without all-traffic the solves leave from the dynamic pool anyway and the NAT address never appears in your firewall log. Google walks through the whole setup in its static outbound IP guide.
Once the reserved address is in place, allow it on the Windows machine’s firewall for the solver’s port, and nothing else. A Cloud VPN tunnel to your own network does the same job without exposing the port to the internet at all. Either way, Server mode is still your hardware and still unmetered. It only changes where the solver listens, so that something other than the same desktop can call it.
Why finishing the solve after the response does not work
The tempting workaround for a slow solve is to answer the caller at once and finish on a background thread. On Cloud Run’s default request-based billing, CPU is allocated only while the instance is processing requests. A thread still polling after the response has gone gets CPU only while some other request is in flight on that instance, and an idle instance can be shut down at any time. The work stalls or disappears, and nothing tells you which.
If the caller really cannot wait, move the work to a Cloud Run job instead. A job has no HTTP request waiting on it, each task may run for 10 minutes by default and up to 168 hours, and jobs take the same network and egress flags as services, so a job given those flags leaves through the same NAT address.
Full working example
The same service, now telling the caller which failures are worth retrying.
# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import (CapSkip, ApiException, NetworkException,
TimeoutException, ValidationException)
app = Flask(__name__)
solver = CapSkip(
host=os.environ["CAPSKIP_HOST"],
port=int(os.environ.get("CAPSKIP_PORT", "8080")),
apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
recaptchaTimeout=300, # keep it below the Cloud Run --timeout
)
@app.post("/solve")
def solve():
job = request.get_json(force=True)
try:
result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
except NetworkException as exc:
# Worth retrying. Log the detail, but do not echo the
# solver's address back to the caller.
app.logger.warning("solver unreachable: %s", exc)
return jsonify(error="solver unreachable"), 503
except TimeoutException:
return jsonify(error="no answer inside 300 seconds"), 504
except (ApiException, ValidationException) as exc:
# Same input, same failure: do not retry.
return jsonify(error=str(exc)), 422
return jsonify(token=result["code"])The status codes are chosen so a caller can act on them without reading the message. A 503 means the solver could not be reached to take the job, and a retry may work. A 504 from your own code, arriving just after 300 seconds, means no answer came back in time, because the solver was slow or dropped off after taking the job. A 422 means CapSkip refused the job, usually because the sitekey, the URL or the API key is wrong, and a straight retry tends to get the same answer. All four exceptions derive from CapSkipError if you would rather catch one thing.
Whoever receives the token should use it at once. A reCAPTCHA token is good for about two minutes, and the details of that window are in the reCAPTCHA v2 solver guide. The same shape works for the other types, which take different arguments and return different fields; every method the Python package exposes is listed on the Python CAPTCHA solver page.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| WORKER TIMEOUT in the logs and a server error about 30 seconds into a solve | The buildpack’s default entrypoint, running gunicorn with its 30 second worker timeout | Set GOOGLE_ENTRYPOINT with a timeout of 0 and a few threads |
| A 504 at 300 seconds, and log lines from the same solve still arriving afterwards | Cloud Run’s request timeout equals the client’s polling timeout, and Cloud Run’s clock started first | Deploy with a timeout of 400 seconds |
| Latency that climbs under load while the instance count stays flat | Cloud Run sends up to 80 requests per vCPU to a server with far fewer threads | Set concurrency to match the gunicorn thread count |
| A NetworkException reading bad response: 404 | The client was built without a host, so it called 127.0.0.1 on port 8080, which inside the container is your own service. It does not read CAPSKIP_HOST by itself | Pass the host from the environment, as main.py does |
| The new revision never becomes ready after a deploy | CAPSKIP_HOST is not set, or gunicorn is missing from requirements.txt | Set the variable on the service, and list gunicorn with your other dependencies |
| Solves that work from your laptop and fail from the service | Your home address is allowed through the firewall and Google’s addresses are not | Give the service a static outbound IP and allow that one |
| Solves that hang until the timeout after you attached the VPC | All traffic goes through a VPC that has no Cloud NAT gateway, so it has no way out | Create the NAT gateway on the service’s subnet |
| Your firewall log shows a Google address you did not reserve | The egress setting is still private-ranges-only, so public traffic skips the NAT | Update the service with all-traffic egress |
FAQ
Can CapSkip itself run on Cloud Run?
No, and it does not need to. CapSkip is a Windows application that runs on hardware you own, and the Python package in your container is a thin client for it over HTTP. Turn on Server mode, give the service the address, and it calls the solver exactly as a script on the same desk would. The solving stays on your machine, which is also why nobody meters how many CAPTCHAs you solve.
Is a service or a job the better fit for solving CAPTCHAs?
A service, when something is waiting for the token and will use it at once, such as a scraper that calls your endpoint mid-crawl. A job, when the work is a batch that nobody is waiting on, because a job has no request timeout at all and its task timeout goes far past an hour. Either way the token has to be used by the process that holds it, within a couple of minutes. A job that solves a hundred CAPTCHAs and writes the tokens somewhere for later has done a hundred solves for nothing. The same trade-off comes up on AWS, and the AWS Lambda guide works through it with a queue in front.
How do I let only my service reach the solver?
Route all of the service’s egress through a VPC with a Cloud NAT gateway holding a reserved address, then allow that one address on your firewall for the solver’s port. A Cloud VPN tunnel reaches a solver on your own network without opening the port to the internet at all. Keep the port closed to everything else, and treat the API key as a second lock rather than the only one.
Does a request waiting on a solve cost much?
Cloud Run bills an instance while it is processing requests, and a request waiting on the network counts. Concurrency is what keeps that reasonable. Eight solves waiting side by side on one instance use one instance’s worth of billed time, where one request per instance would start eight. That is the other half of the case for giving gunicorn eight threads instead of one. The solve itself costs nothing per CAPTCHA, because it runs on your own machine.
The short version
A Cloud Run captcha deploy needs four settings, and three of them live in the deploy command rather than in your code. Replace the buildpack’s default entrypoint so gunicorn stops killing workers at 30 seconds, and give it threads. Set the request timeout above the client’s 300 seconds so your own error arrives before Cloud Run’s 504. Match concurrency to those threads so extra load starts instances instead of queueing. And pass the Server mode address to the client yourself, then route the service through a VPC with Cloud NAT and all-traffic egress, so your firewall has exactly one address to allow.
One last point about the economics, because it is what makes the retry codes above comfortable to act on. A retried request costs you some Cloud Run seconds and nothing else: the captcha solver itself runs on hardware you already paid for, so a failed solve never adds a per-CAPTCHA charge from anyone.
