Deploy contract
What CloudGrid does to your code on the way to a running service, and what you can change about it.
The platform owns the build recipe. You write source code and cloudgrid.yaml; the platform turns that into a container image, a Kubernetes Deployment, a Service, an Ingress, probes, and env injection. This page lists what the platform decides, what you control, and what is not user-overridable today.
If you came here from a build failure asking "where do I change X" — start with the override table and the versioning policy. If X is not in the override table, it is not user-changeable today; the path is to ask for it, not to work around it.
1. What the platform decides for you
These are platform choices. They apply to every service of the given type. No yaml field overrides them today.
Container port
Every service container listens on 8080 by default, regardless of type. The per-service port: yaml override swaps the listener for individual services whose framework hard-codes a different port — the default below is what applies when port: is absent.
- The platform injects
PORT=<container-port>into the runtime environment (8080 by default; the per-serviceport:override when declared). Node and Python services readprocess.env.PORT/os.environ['PORT']; the synthesized nginx config (for static) is rendered withlisten <container-port>. The Dockerfile template'sEXPOSEdirective is substituted from the same value, so the image, the manifest, and the probes all agree on a single number. - The Kubernetes
containerPort, the ServicetargetPort, all three probes, and thePORTenv var all align on the same number. You can shift them together withport:; you can't make them disagree. - Cluster-facing Service
port:stays at80regardless — that is the port behind the internal URL the platform injects for service-to-service calls (a cluster-internal address of the shapehttp://<service>.<namespace>.svc.cluster.local:80; you never construct it by hand). The Service maps 80 to the container port; sibling URLs auto-injected as<UPPER>_URLalways point at port 80.
Base image per service type
The platform's build pipeline synthesizes a Dockerfile from the service's type:. You do not supply one. The base images today:
type: | Base image (runtime) | Builder image (when applicable) |
|---|---|---|
node | node:22-alpine | — |
node + lang: typescript | node:22-alpine | node:22-alpine (stage 1: tsc) |
nextjs | node:22-alpine | node:22-alpine (stage 1: next build) |
python | python:3.12-slim | — |
static | nginx:1.28-alpine | node:22-alpine (when build: is declared) |
cron (run: job) | inherits from detected language (node or python) | — |
cron (run: http(s)://…) | no image — platform requests the URL on schedule | — |
Static-build runner
For type: static with a build: block, the platform runs your command (e.g. npm run build) in a build step using the runner image node:22-alpine (Node 22 LTS). This is the floor for Astro 6, Next.js 16, and Vite 7.
Synthesized nginx config (static services)
The platform writes the nginx config for type: static at image-build time. You do not supply nginx.conf. The synthesized config:
- Listens on 8080.
- Serves from
/usr/share/nginx/html/. - Routes a
GET /healthto a literal200 ok. - Falls through to
index.htmlfor any unmatched path (SPA-style).
If a nginx.conf is present in your service folder, it is ignored. The platform does not expose nginx tunables (gzip, cache headers, rate limits) through yaml today.
User Dockerfile — always ignored
Any Dockerfile in your project is silently stripped before the upload reaches the build, regardless of whether one exists at the project root, in services/<svc>/, or anywhere else.
You do not need to fix a failing build by editing a Dockerfile. The fix is one of:
- Make sure the entry file the platform expects exists (see the per-type entry-file contract in the
CLOUDGRID.mdreference the CLI installs into each project). - Bind your server to
process.env.PORT, not a hard-coded port. - Add the missing dependency to
package.json/requirements.txt. - Make sure a
build:block runs for frameworks that need a build step (Astro, Vite, SvelteKit — but not Next.js, which has its own template).
Env injection (auto-injected, in this order)
Every service container receives env vars in this order. Later sources override earlier ones, but the platform protects reserved keys against override.
- Reserved platform vars.
PORT,APP_NAME,SERVICE_NAME,NODE_ENV,CLOUDGRID_ORG_ID,CLOUDGRID_ENTITY_ID,RUNTIME_GATEWAY_URL. - Resource URLs for whatever the entity declares in
requires:—MONGODB_URL,REDIS_URL,N8N_WEBHOOK_URL, plus the GCP service account binding whengcp:is declared. - Sibling-service URLs — one
<UPPER_SVC>_URLper other service in the same yaml (e.g. awebservice that has a siblingapigetsAPI_URL). - Vault entries declared in the top-level
vault:block — name on the left of each entry, resolved value on the right. - Static env from yaml — anything under
services.<svc>.env:. - Runtime env — anything set via
grid env set. - Secrets — anything set via
grid secrets set(mounted from a per-entity Kubernetes Secret).
The platform's reserved keys cannot be overridden by the last three sources. grid env set PORT=… fails with RESERVED_KEY.
Probes
By default the platform configures Kubernetes startup, liveness, and readiness probes as TCP probes on the container port. A successful TCP connect counts as ready. You do not need a /health route.
You can opt in to a stricter HTTP probe per service with health: in yaml:
health: value | Probe shape |
|---|---|
absent / false | TCP probe on the container port (default) |
true | HTTP GET on /health |
'/api/health' | HTTP GET on that path |
A separate external rollout probe (run after rollout) hits / and treats any HTTP response below 500 as up — so a 404 from a Next.js root still passes the rollout gate.
Image policy
- The Deployment's
image:field is always<registry>/<image>:latest. - Each build pushes both
:latestand:sha-<short>to the registry; the sha tag exists for rollback and forensics. - Rollback re-tags a chosen sha as
:latest(seegrid rollback).
You do not manage image tags or build numbers.
2. What you control via cloudgrid.yaml
The field-by-field reference is the CLOUDGRID.md file the CLI installs into each new project. Short summary of what is under your control today:
- What services exist — the
services:map. Each service has atype:, optionally apath:, optionally ahealth:opt-in. - What shared resources the entity needs —
requires:(mongodb, redis, n8n, gcp services). - How services depend on each other at startup —
depends_on:per service. - What env vars and secrets the service sees —
env:in yaml,grid env set,grid secrets set, top-levelvault:. - For static sites —
build:block withcommand:and optionaloutput:directory. - Cron services —
schedule:,run:,timezone:. - Where the source for each service lives —
source.path:(default isservices/<svc>/). - Public exposure —
expose: falseto keep an entity internal (no Ingress, no public URL). - Custom domains —
custom_domains:list (additive to the default URL).
Anything not listed here — base image, Node version for server types, nginx config, build command flags for synthesized Dockerfiles — is platform-managed today.
3. What you can override and what you can't
| Knob | User-controllable? | How | Notes |
|---|---|---|---|
| Container port | Yes | services.<svc>.port: <1-65535> (any non-cron type) | Default 8080. Sets containerPort, all three probes, Service.targetPort, and the auto-injected PORT env var. Service.port (the cluster-facing port reached through the Ingress) stays at 80 regardless. |
| Service base image | No | — | Platform-chosen per type:. |
Node version (node, nextjs) | No | — | Node 22 LTS today. The node_version: knob (below) is static-only for now. |
| Node version (static-build runner) | Yes | services.<svc>.node_version: '18' | '20' | '22' | '24' (static type only) | Default '22' (Node 22 LTS). Falls through to the platform default when absent. Quote the value — bare yaml integers are rejected. |
| Python version | No | — | Python 3.12 today. |
| nginx config (static services) | No | — | Synthesized at build time; nginx.conf in your repo is ignored. Tunables (gzip, cache headers) not exposed. |
| Dockerfile | No (always ignored) | — | A Dockerfile in your upload is stripped. |
| Probe shape | Yes | services.<svc>.health: (absent / true / '/path') | Default TCP; opt in to HTTP. |
| Build command (static) | Yes | services.<svc>.build.command: | Required for frameworks that need a build step. |
| Build output dir (static) | Yes | services.<svc>.build.output: (default dist) | Where the platform expects index.html after build. |
| Build-time env (static) | Yes | services.<svc>.build.env: (string-to-string map) | Build-time only — values are exported into the static-build step before command runs and are baked into the bundle (Vite VITE_*, Next.js export NEXT_PUBLIC_*, build-time feature flags). They do not reach the running container. Reserved keys: PORT, APP_NAME, SERVICE_NAME, NODE_ENV, and the CLOUDGRID_* namespace. The _URL suffix is not reserved here (that reservation is runtime-only; VITE_API_URL and friends are the canonical use). |
| Env injected into the pod | Yes | services.<svc>.env:, grid env, grid secrets, vault: | Reserved keys (PORT, APP_NAME, …) cannot be overridden. Runtime only — for build-time env see the row above. |
| Sibling-service URLs | No (auto) | — | One <UPPER_SVC>_URL per sibling. |
| Resource URLs (Mongo, Redis, …) | No (auto) | — | Injected when declared in requires:. |
| Image tag in Deployment | No | — | Always :latest. |
If a knob you need is in the "No" column and you have a real use case, ask for it — don't work around the platform.
4. Smart defaults from package.json
At plug time the platform reads each uploaded service's package.json and fills yaml fields you left blank. The mechanism is additive — an explicit yaml field is never overridden; a smart default only applies when the yaml side is silent.
Precedence
Highest to lowest:
- Explicit yaml field — whatever appears in
cloudgrid.yaml. - Smart default — detected from
package.jsonwhen the yaml is silent. - Platform default — the values above (Node 22 LTS, port 8080, no build command).
Any yaml that worked before this layer existed continues to deploy identically — smart defaults can only fill blanks.
Detection table
| Field | Detection rule | Yaml slot filled when silent |
|---|---|---|
framework | dependencies.next → next; .astro → astro; .remix → remix; @sveltejs/kit → sveltekit; @vitejs/plugin-react → vite-react; @vitejs/plugin-vue → vite-vue; vite (bare) → vite; .express → express; otherwise → static | (no yaml slot today — logged only) |
node_version | engines.node semver range → highest supported major in {18, 20, 22, 24} that satisfies the range | services.<svc>.node_version (static type only) |
port | scripts.start parsed for --port=NNNN, --port NNNN, -p NNNN, or PORT=NNNN node … | services.<svc>.port (non-cron types) |
build_command | scripts.build verbatim | services.<svc>.build.command (static type only) |
start_command | scripts.start verbatim | (no yaml slot today — logged only) |
The semver-range resolver picks the highest matching supported major. engines.node: ">=20" resolves to 24; ^20 resolves to 20; >=18 <22 resolves to 20. A range that excludes every supported major (e.g. <18) resolves to nothing and the platform default applies.
What happens at plug time
For every service whose upload includes a parseable package.json, the deploy log surfaces one line:
Detected next from package.json. Using node_version 24, build_command 'next build'.
A service whose only signal is the static fallback (no framework dependency detected, no other field changed) produces no line — there is nothing to learn from a no-op detection.
Edge cases
- No
package.json(static-only entity,index.htmlupload): smart defaults skip that service silently; platform defaults apply. - Malformed
package.json(invalid JSON, JSON-array root): treated as no signal, same as missing. The deploy does not fail. - Validator-incompatible smart default: silently dropped. Example:
engines.nodedetected for atype: nodeservice does not fillnode_version(the field is static-only — see the override table). - Rollback: smart defaults skip entirely. The rollback target's previously-validated yaml is what re-deploys.
5. Versioning policy
When the platform changes a base image or a runner version, it is a fleet-wide change — every service of the affected type rebuilds on its next grid plug and runs against the new floor.
What counts as a version change
- A new minor or major for the runtime base image (
node:20-alpine→node:22-alpine,nginx:1.27-alpine→nginx:1.28-alpine,python:3.11-slim→python:3.12-slim). - A new minor or major for the static-build runner image.
- A change to the synthesized nginx config (new directives, changed defaults).
- A change to env injection order, or a new reserved key.
Patch-level bumps within the same minor (e.g. node:22-alpine → node:22.10-alpine as the upstream image rolls forward) are not announced — the alpine tag is a moving floor and re-pulls track upstream.
Announcement mechanism
Every version change ships via a CloudGrid release. The release note names the change, the floor it lifts, and any known-incompatible frameworks.
Why bumps happen
- A framework version your services use stops supporting the current floor (the Astro 6 / Node 22 case).
- A security advisory against the current floor (Alpine, openssl, upstream language runtime).
- A platform feature needs a runtime capability only available in a newer base.
The platform avoids bumps that don't gain anything. Bumps are intentional, named, and tied to a rollout date.
Backwards compatibility
The platform tries to keep bumps backwards-compatible: a service that worked on the previous floor should keep working on the new floor. When a bump cannot be backwards-compatible — for example, dropping a Python minor — the release note calls it out and the platform offers a version lever (like node_version:) for that cycle.