Google’s message is one sentence and it contains the whole diagnosis: the container did not accept a connection on the port Cloud Run picked, within the time Cloud Run allowed. Everything below is deciding which of those two clauses failed.
The error, exactly
The string Cloud Run returns, documented on Google’s Cloud Run troubleshooting page, is:
Container failed to start. Failed to start and then listen on
the port defined by the PORT environment variable.
It is a deploy-time failure, not a runtime one. The revision is created, the container is run once, the health check fails, and the revision is marked as failed without ever receiving traffic. The practical consequence is worth saying out loud because it changes how urgent this is: the previously healthy revision keeps serving. Your service is not down. You have a broken revision sitting next to a working one, and you can debug at a normal pace.
There are exactly two ways to fail the check. Either something answered on the wrong port, or on the wrong interface, or nothing answered at all — and that last case splits again into “still starting” and “already dead”. The logs distinguish them and the error message does not, which is why reading the message twice does not help and reading stderr does.
PORT is assigned to you, not chosen by you
Cloud Run injects a PORT environment variable into every container instance and expects the process to bind to that value. It is 8080 unless you set --port at deploy time, and this is where most people go wrong: they read “8080”, hardcode 8080, and it works — until somebody deploys with --port=3000 and the hardcoded service stops matching the contract. Read the variable.
The second and more common version is a framework whose default port is something else entirely. Express defaults to nothing and people write 3000. Next.js defaults to 3000. Flask’s development server defaults to 5000. Uvicorn defaults to 8000. Every one of those produces this error unchanged.
// Node / Express — read PORT, fall back to 8080, bind 0.0.0.0
const port = Number(process.env.PORT) || 8080;
app.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
# Python / FastAPI — same contract, in the Dockerfile CMD
CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}
The exec form in that CMD is not decoration. Without it, the shell becomes PID 1 and your server becomes a child, which means the container does not receive SIGTERM cleanly on shutdown. That is a different bug, but it lives in the same line and you may as well fix both while you are here.
The right port on the wrong interface
This is the failure that wastes the most time, because the port number in the deploy command matches the port number in the code and the error still says the port is wrong. Google’s runtime contract is explicit that the service must listen on 0.0.0.0 and not 127.0.0.1.
The mechanism is straightforward once you see it. The health probe does not originate inside your process’s loopback interface; it arrives over the container’s network interface from Cloud Run’s side. A socket bound to 127.0.0.1 accepts connections that originate on the loopback address and nothing else, so the probe gets a connection refused and the platform reports the only thing it can observe, which is that nothing listened.
The defaults are against you here too. app.run() in Flask binds 127.0.0.1. Uvicorn’s default --host is 127.0.0.1. Rails bound to localhost by default for years. If you are porting something that ran fine on a laptop, the laptop was reaching it over loopback and the interface question never came up.
When the port is right and it still times out
If the bind is correct, the container is simply not finishing in time. This is the normal failure for anything that loads model weights, builds an index, or opens a warm connection pool at import time.
Cloud Run’s default startup probe, per Google’s health-check configuration documentation, is a TCP probe with timeoutSeconds 240, periodSeconds 240 and failureThreshold 1 — so you already have up to 240 seconds, and the documented ceiling is that failureThreshold × periodSeconds may not exceed 240 seconds either. That is the important number: you cannot buy your way out of a five-minute startup by raising the probe, because the maximum is four minutes and you are already at it by default.
The 240-second ceiling and the default probe values are what Google documents at the time of writing. Probe defaults are the kind of platform figure that moves; check the health-check page before designing around the exact number.
gcloud run deploy inference-svc \
--image=us-docker.pkg.dev/PROJECT_ID/repo/inference:1.4.0 \
--region=us-central1 \
--port=8080 \
--startup-probe=httpGet.path=/healthz,httpGet.port=8080,\
initialDelaySeconds=10,periodSeconds=20,failureThreshold=12,timeoutSeconds=5
Because the ceiling is fixed, the real fix for a slow model service is to stop doing the slow work before you listen. Bind the port first, start the weight load on a background task, and have the startup probe path return 503 until the load completes. Cloud Run will keep probing up to your failureThreshold, and the instance only enters rotation once the path returns 200. The trade is that you now have a real readiness signal to maintain, and a bug in it means an instance that serves requests before it can answer them. Baking the weights into the image, where their size allows it, avoids the whole question — see how model weights get into a deployment for the version of that argument that is not specific to Cloud Run.
The container that dies before it can listen
If stderr has content, read it first; this case is usually five seconds of work and the error message gives you no hint that it applies.
gcloud logging read \
'resource.type="cloud_run_revision"
resource.labels.service_name="inference-svc"
severity>=ERROR' \
--limit=50 --format='value(textPayload)' --freshness=1h
Three causes account for most of these. An architecture mismatch: images built on an Apple Silicon machine default to arm64, and Cloud Run requires a 64-bit x86 Linux image, so you need docker build --platform linux/amd64 or the equivalent build argument. A missing secret: if the revision mounts a Secret Manager secret and the runtime service account lacks roles/secretmanager.secretAccessor, the container never starts and you get this same message rather than a permissions error — mounting and rotating a provider key covers the grant. And crash-on-import: a config value read at module load, absent in the deployed environment, raising before the server object is ever constructed.
Reproducing it locally in one command
Every case above reproduces on a laptop, and none of them requires a deploy to observe. Run the image with the variable Cloud Run would inject and check the interface, not just the port:
docker run --rm -e PORT=8080 -p 8080:8080 \
us-docker.pkg.dev/PROJECT_ID/repo/inference:1.4.0
# from another shell — this is the check that catches 127.0.0.1,
# because -p maps the container's external interface, not its loopback
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8080/healthz
A container bound to loopback inside will fail that curl with a connection reset while docker exec plus a local curl succeeds — which is exactly the asymmetry Cloud Run is reporting. If the local run succeeds and the deploy still fails, the remaining differences are the image platform, the service account, and the environment variables the revision actually has, in that order of likelihood.
Top comments (0)