The API left the cluster and landed on Cloud Run.
The Dockerfile had port 8000 hardcoded in four separate places: the ENV block, EXPOSE, the HEALTHCHECK URL, and the gunicorn --bind flag. Cloud Run injects a dynamic PORT at runtime. A hardcoded port means the process binds to the wrong address, the health check fires against the wrong port, and Cloud Run kills the container before the first request. All four were fixed. The image was built and verified locally at PORT=9000, then deployed to Cloud Run europe-west3. The /health endpoint returned 200 from the live URL. A curl against /predict with a real image returned five ResNet-50 predictions.
The Kubernetes cluster was the deployment target for the last fourteen transmissions. Cloud Run is the target now.
The constraint
Cloud Run is serverless. It assigns a port at startup and injects it as an environment variable called PORT. The process inside the container must bind to 0.0.0.0:$PORT. If it binds to a fixed address instead, Cloud Run health checks the port it told the container to use, gets no response, and kills the container before the first request lands.
The Dockerfile had 8000 written in four places:
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# no PORT variable defined
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD ["python", "-c", \
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
EXPOSE 8000
CMD ["gunicorn", "main:app", \
"--workers", "1", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--timeout", "120"]
The CMD was in JSON exec-form. Exec-form passes arguments directly to the kernel with no shell involved. Writing ${PORT} inside a JSON array does nothing — the shell never runs, so the variable is never substituted.
docker-compose.yml had the same hardcoded value in three places: the --bind flag, the ports mapping, and the healthcheck test command.
The proof
The diff
Four changes to Dockerfile:
ENV PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1
+ PYTHONUNBUFFERED=1 \
+ PORT=8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
- CMD ["python", "-c", \
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ CMD python -c \
+ "import os, urllib.request; urllib.request.urlopen('http://localhost:' + os.environ.get('PORT','8000') + '/health')"
-EXPOSE 8000
+EXPOSE ${PORT}
-CMD ["gunicorn", "main:app", \
- "--workers", "1", \
- "--worker-class", "uvicorn.workers.UvicornWorker", \
- "--bind", "0.0.0.0:8000", \
- "--timeout", "120"]
+CMD gunicorn main:app \
+ --workers 1 \
+ --worker-class uvicorn.workers.UvicornWorker \
+ --bind "0.0.0.0:${PORT}" \
+ --timeout 120
PORT=8000 in the ENV block sets the fallback for local docker run. Cloud Run overrides it at runtime. CMD is now in shell form so /bin/sh expands ${PORT} before passing it to gunicorn.
Three changes to docker-compose.yml:
- --bind 0.0.0.0:8000
+ --bind 0.0.0.0:${PORT:-8000}
- - "8000:8000"
+ - "${PORT:-8000}:${PORT:-8000}"
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ "import os, urllib.request; urllib.request.urlopen('http://localhost:' + os.environ.get('PORT','8000') + '/health')"]
Compose uses ${VAR:-default} syntax. If PORT is not set in the environment or a .env file, it falls back to 8000. Local behaviour is unchanged.
Local verification
Before pushing to Cloud Run I verified the binding worked locally by running the image with -e PORT=9000:
[2026-08-22 08:15:33 +0000] [7] [INFO] Starting gunicorn 22.0.0
[2026-08-22 08:15:33 +0000] [7] [INFO] Listening at: http://0.0.0.0:9000 (7)
[2026-08-22 08:15:33 +0000] [7] [INFO] Using worker: uvicorn.workers.UvicornWorker
[2026-08-22 08:15:33 +0000] [8] [INFO] Booting worker with pid: 8
[2026-08-22 08:15:39 +0000] [8] [INFO] Started server process [8]
[2026-08-22 08:15:39 +0000] [8] [INFO] Waiting for application startup.
[2026-08-22 08:15:40 +0000] [8] [INFO] Application startup complete.
Gunicorn bound to 0.0.0.0:9000. The image picks up whatever port you give it.
The full build took 1681 seconds. PyTorch download and ResNet-50 weight bake dominate that time. Subsequent builds skip those layers from cache.
[+] Building 1681.7s (15/15) FINISHED
=> [builder 4/5] RUN python -m venv /opt/venv && pip install -r requirements.txt 923.1s
=> [runtime 4/6] RUN python -c "import torchvision; ..." 251.4s
=> exporting to image 305.3s
=> => naming to docker.io/library/simple-model-api:test
Cloud Run deployment
Service deployed in europe-west3. All three creation steps completed in under two minutes:

Fig 1: Revision simple-model-api-00001-zkc receiving 100% of traffic, deployed to europe-west3.
Health check
$ curl https://simple-model-api-547441435199.europe-west3.run.app/health
{"success":true,"status":"healthy","model":"ResNet-50","version":"1.0.0"}
Prediction
$ curl -X POST "https://simple-model-api-547441435199.europe-west3.run.app/predict" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-F "file=@test_image.jpg"
{
"success": true,
"predictions": [
{"rank":1,"class_index":490,"class_name":"chain mail","confidence":0.070772},
{"rank":2,"class_index":903,"class_name":"wig","confidence":0.035625},
{"rank":3,"class_index":643,"class_name":"mask","confidence":0.031815},
{"rank":4,"class_index":488,"class_name":"chain","confidence":0.026007},
{"rank":5,"class_index":219,"class_name":"cocker spaniel","confidence":0.020445}
],
"meta": {"model":"ResNet-50","top_k":5,"filename":"test_image.jpg"}
}
The model returned five predictions. Top confidence is 7% — ResNet-50 is uncertain, which is expected for a mixed image. The endpoint is working.
Where this sits
| Item | Status |
|---|---|
PORT=8000 default added to Dockerfile ENV block | Done |
HEALTHCHECK switched to shell form, reads $PORT at runtime | Done |
EXPOSE updated to ${PORT} | Done |
CMD switched from exec-form to shell form, binds 0.0.0.0:${PORT} | Done |
docker-compose.yml --bind, ports, and healthcheck updated to ${PORT:-8000} | Done |
Local smoke test at PORT=9000 confirmed gunicorn bound to the right address | Done |
Image deployed to Cloud Run europe-west3 | Done |
/health returned 200 from the live URL | Done |
/predict returned five ResNet-50 predictions from a real image | Done |
The service is live. https://simple-model-api-547441435199.europe-west3.run.app is the address.