[Docker]

Buildx multi-target images, Compose profiles, and Cloud Run production contract for the PYNE Pro API.

Docker

Abstract

Containers package the Pro API (Flask + evaluator) for reproducible local runs and Cloud Run deploys. A single multi-stage Dockerfile exposes named targets; Docker Buildx Bake (docker-bake.hcl) drives local loads and multi-platform release builds. Compose wires volume mounts and optional Redis/LSP profiles for desk development, with a production overlay for gunicorn.

Conceptual model

Interface surface

Dockerfile targets

TargetProcess modelAudience
apientrypoint-api.sh → gunicorn (0.0.0.0:$PORT)Production / Cloud Run / compose prod
api-devpython -m backend.app with HOST=0.0.0.0Local compose (source mounts)
lsppython -m pynescript.langserver (stdio)Compose profile lsp

All targets: Python 3.12-slim, non-root appuser (uid 1000), API_KEY_STORE=/data/api_keys.json, MPLBACKEND=Agg, healthcheck via curl (HTTP targets).

Buildx Bake (docker-bake.hcl)

# Local load (host platform)
docker buildx bake api                 # production image → pynescript-api:latest
docker buildx bake                     # default group: api + api-dev
docker buildx bake all                 # api + api-dev + lsp

# Multi-platform (amd64 + arm64). Set REGISTRY to push:
REGISTRY=gcr.io/$PROJECT/pynescript TAG=$COMMIT_SHA docker buildx bake release

Make wrappers:

make docker-build       # bake api
make docker-build-all   # bake all targets
make docker-buildx      # bake release (multi-platform)

One-time multi-platform builder (if needed):

docker buildx create --use --name pynescript

Compose

cp .env.example .env   # optional overrides

make docker-up         # api-dev on host :5002
make docker-up-full    # + redis profile (api + redis)
make docker-prod       # requires ADMIN_TOKEN; gunicorn, no source mounts
make docker-smoke      # curl http://127.0.0.1:5002/
make docker-down

# stdio LSP (not a long-running compose service)
docker compose --profile lsp run --rm lsp
ServiceProfileNotes
apidefaultPort ${API_PORT:-5002}:8080, ro mounts of src/ + backend/, volume api_data/data
redisredisredis:7-alpine, AOF, ${REDIS_PORT:-6379}
lsplspstdio; use docker compose run --rm lsp (not detached up)

Network name: pynescript-dev.

Production overlay (docker-compose.prod.yml): target api, volumes: !override so only api_data:/data remains (dev source binds are dropped), SQLite key store on /data, mem/cpu caps, requires ADMIN_TOKEN.

Environment variables

VariableDefaultMeaning
PORT8080Listen port inside the container
HOST0.0.0.0Bind address (dev Flask runner)
API_PORT5002Host port published to container 8080
FLASK_ENVproduction / developmentApp mode by target
STORE_BACKENDjson (prod overlay: sqlite)json | sqlite | redis
API_KEY_STORE/data/api_keys.jsonJSON key store path (STORE_BACKEND=json)
API_KEY_STORE_SQLITE/data/api_keys.dbSQLite path (STORE_BACKEND=sqlite)
ALLOWED_ORIGINSsee .env.exampleCORS allow-list
ADMIN_TOKENunsetRequired for POST /auth/create_key (X-Admin-Token) and prod compose
REDIS_URLempty in prod; compose dev default redis://redis:6379/0Required when STORE_BACKEND=redis
GUNICORN_WORKERS / GUNICORN_THREADS / GUNICORN_TIMEOUT2 / 4 / 60Prod entrypoint knobs
GUNICORN_BIND0.0.0.0:${PORT}Optional gunicorn bind override

Cloud Build image tags

From cloudbuild.yaml (builds --target api):

gcr.io/$PROJECT_ID/pynescript/pynescript-pro-api:$COMMIT_SHA
gcr.io/$PROJECT_ID/pynescript/pynescript-pro-api:latest

Internals

Build stages

  1. base — slim image, curl + freetype/png for matplotlib Agg, appuser, /data
  2. builderbuild-essential, pip cache mounts, install backend/requirements.txt then pip install ".[lsp]" into /install (requires pyproject.toml, README.md, LICENSE, NOTICE, src/, backend/)
  3. runtime — copy prefix + sources, PYTHONPATH=/app/src:/app so compose mounts override site-packages
  4. api / api-dev / lsp — final CMDs and healthchecks

.dockerignore

Keeps context small by excluding tests/ (corpus), docs/, brand/, node modules, compiler *.nbc/*.nbi caches, venvs, and IDE cruft.

Compose healthchecks

API: curl -fsS http://127.0.0.1:8080/. Redis: redis-cli ping.

Invariants & edge cases

  1. Host port 5002 vs container 8080. AXIS local docs assume API on 5002 via compose; Cloud Run uses 8080.
  2. Read-only mounts in compose mean dependency changes need image rebuild; source edits are visible via PYTHONPATH precedence.
  3. Production target is immutable — no live mounts; use the prod overlay or Cloud Run.
  4. Non-root appuser cannot write arbitrary paths; keep API_KEY_STORE under /data.
  5. Cloud Build always builds --target api (gunicorn). Do not point it at api-dev.
  6. LSP is stdio — not an HTTP service; use docker compose run --rm lsp rather than expecting a published port.

Worked examples

Production-like local API (bake)

docker buildx bake api
docker run --rm -p 8080:8080 \
  -e FLASK_ENV=production \
  -e ALLOWED_ORIGINS=http://localhost:8081 \
  pynescript-api:latest
curl -s http://127.0.0.1:8080/ | jq .

Compose with Redis

docker compose --profile redis up --build

Optional LSP container

docker compose --profile lsp run --rm lsp

Production overlay

export ADMIN_TOKEN=change-me
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d

Failure modes

SymptomCauseFix
Healthcheck failsprocess not listening / curl missingUse image targets from this Dockerfile; check docker compose logs api
Connection refused on host portFlask bound to 127.0.0.1Dev target sets HOST=0.0.0.0; do not override to localhost
Permission denied writing keysRunning as appuser outside /dataSet API_KEY_STORE=/data/... and mount api_data
Import errors after mountPYTHONPATH / stale site-packagesPYTHONPATH=/app/src:/app; rebuild after dependency changes
OOM under loadSmall memory capRaise API_MEM_LIMIT / Cloud Run memory; reduce concurrency
CORS failures from PWAOrigin not allowedSet ALLOWED_ORIGINS
Huge build contextMissing .dockerignoreEnsure .dockerignore is present (corpus/docs excluded)

See also