Containers
A container app is a server that keeps running: a Node server, a Python app, a Go or Rust binary, or anything with a Dockerfile. jiayang deploy builds the image on your machine, pushes it to the platform, and runs it on Cloudflare Containers, reachable only through the platform’s sign-in.
What runs as a container
Section titled “What runs as a container”Detection makes a directory a container when it has a Dockerfile, a Node server in package.json (or a start script), requirements.txt, pyproject.toml or Pipfile, go.mod, or Cargo.toml. A framework that renders on a server with no Cloudflare adapter, such as Next.js without OpenNext, is a container too.
To make an app a container whatever the directory holds, say so when you create it:
jiayang deploy acme/api ./api --create --kind containerAn app can’t change between a container and a Worker later.
Container apps need a paid plan. On Free the deploy stops before it builds anything:
error: The Free plan runs JS and static apps only. Container apps (Python, Go, Rust, Node servers) need a paid plan: Team runs container apps. Upgrade the workspace at https://app.jiayang.cloud/w/acme/billingWhat you need
Section titled “What you need”Docker, running on your machine. The image is built with docker build --platform linux/amd64, because that’s what Cloudflare Containers run. Set JIAYANG_DOCKER to use another program with the same commands.
An image built for another architecture is refused before it’s pushed:
error: the image was built for linux/arm64, but the platform runs linux/amd64: is the Dockerfile's FROM pinned to another architecture?Deploy one
Section titled “Deploy one”jiayang deploy acme/api . --createa container (Express) because package.json depends on express, runs as a node container start: npm startBuilding . for linux/amd64 ...Pushing sha256:9e2d71c04af3 (1 MB) ...Pushing sha256:4b1e0f9d2c7a (29 MB) ...Pushing sha256:c81f5a0e36d9 (41 MB) ...It listens on port 8080 and runs at size basic.Deployed version 1 (7c3a91e0d5f2).https://api--acme.jiayang-apps.cloudA port: line shows up under start: only when jiayang.json sets one. A layer the platform already has isn’t pushed again, and one the registry fails on is sent again, a few times, before the deploy gives up.
The first requests after a deploy can get a 503 that says The app is starting. Try again in a few seconds. while the container comes up. A browser gets a page saying so, which reloads itself until the app answers. After an app’s first deploy that can last several minutes. Troubleshooting has more.
If it never comes up, or answers The app didn't answer. with a 502, it most likely exited. jiayang logs acme/api shows what it wrote to stdout and stderr on the way.
Deploy again
Section titled “Deploy again”The next deploy or a rollback replaces the running container with a rollout. Cloudflare sends the running container SIGTERM, gives it up to 15 minutes to exit, and then starts the new one. Until it has stopped, the version before keeps answering, and the deploy says so:
Deployed version 2 (b42d07e1c9aa).https://api--acme.jiayang-apps.cloudVersion 1 keeps answering until its container has stopped and this one's has started. Cloudflare stops it with SIGTERM and gives it up to 15 minutes to exit, so how long that takes is mostly how quickly the app exits on SIGTERM.A change to the app’s environment variables doesn’t wait for that. It starts a rollout too, with the same image, but the first request after the change stops the running container if the rollout hasn’t yet: SIGTERM, then SIGKILL if it hasn’t exited within two seconds. A new container then starts with the new values.
An app that exits as soon as it gets SIGTERM is replaced quickly. One that ignores it, which a shell entrypoint often does, holds the old version up for the full 15 minutes. Start your server with exec, or use the CMD ["node", "server.js"] form, so the signal reaches it. A server running as process 1 ignores SIGTERM unless it handles it, so in your own Dockerfile also add an init such as tini, the way a Dockerfile jiayang writes for you does.
With your own Dockerfile
Section titled “With your own Dockerfile”Your Dockerfile is used as it is, with the app’s directory as the build context. Listen on the port in PORT, which is 8080 unless you set another, on every interface (0.0.0.0).
FROM node:24-alpineWORKDIR /appCOPY . .USER nodeCMD ["node", "server.mjs"]The build reads your .dockerignore, or Dockerfile.dockerignore when there is one.
Without a Dockerfile
Section titled “Without a Dockerfile”jiayang deploy writes a Dockerfile from what the project is, builds with it from a scratch directory, and leaves your project as it was. jiayang dockerfile prints the one it would use:
jiayang dockerfile# Written by `jiayang deploy`. Run `jiayang dockerfile --write` to keep it and edit it# yourself; after that this app builds from your copy.
FROM golang:1.27-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS buildWORKDIR /srcCOPY go.mod go.su[m] ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -o /out/server .RUN apt-get update \ && apt-get install -y --no-install-recommends tini \ && rm -rf /var/lib/apt/lists/*
FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7abENV PORT=8080COPY --from=build /usr/bin/tini-static /usr/bin/tiniWORKDIR /appCOPY . .COPY --from=build /out/server /app/serverEXPOSE 8080ENTRYPOINT ["/usr/bin/tini", "-g", "--"]CMD ["/app/server"]--write puts it in the project, with a .dockerignore, and from then on the app builds from your copy:
jiayang dockerfile --writeWrote ./Dockerfile and ./.dockerignore.Every Dockerfile jiayang writes pins its base images by digest, sets PORT=8080, and runs its last stage as a user other than root. None takes a build argument or mounts a secret. Each starts the app under tini, so the app isn’t process 1: SIGTERM reaches it and a rollout doesn’t wait out the 15 minutes for an app that never set up a handler.
The runtime comes from the project’s files: package.json is Node, a Python dependency file is Python, go.mod is Go and Cargo.toml is Rust. Name it yourself with container.runtime in jiayang.json.
Built on node:24-slim in one stage and run in another. The install follows your lockfile: npm ci, pnpm install --frozen-lockfile or yarn install --immutable through corepack, or bun install --frozen-lockfile. With no lockfile it runs npm install. Then npm run build if package.json has a build script, and npm prune --omit=dev. It runs as the node user and starts with npm start, or for SvelteKit with @sveltejs/adapter-node and no start script, with node build.
Python
Section titled “Python”One stage on python:3.13-slim. The install is uv sync --frozen --no-dev with a uv.lock, poetry install --no-root --only main with a poetry.lock, otherwise pip install -r requirements.txt, or pip install . from pyproject.toml. It runs as a user named app.
The start command comes from what the project depends on, checked in this order:
| Depends on | Starts with |
|---|---|
streamlit |
streamlit run app.py --server.port $PORT --server.address 0.0.0.0 --server.headless true |
gradio |
python app.py |
fastapi |
uvicorn main:app --host 0.0.0.0 --port $PORT |
django |
gunicorn <project>.wsgi:application --bind 0.0.0.0:$PORT |
flask |
gunicorn app:app --bind 0.0.0.0:$PORT |
| anything else | python main.py |
The module is the first of main.py, app.py and server.py that exists. Streamlit runs the first of app.py, main.py and streamlit_app.py. For Django, <project> is the directory holding wsgi.py, and the image runs collectstatic when there’s a manage.py. gunicorn and uvicorn are installed when the start command uses them. A Gradio app or a plain script has to listen on 0.0.0.0 at the port in PORT itself.
When nothing says how the app starts, the deploy asks:
error: there's no main.py or app.py to run, and nothing here says how this starts. Name the command in jiayang.json's container.start.Built with golang:1.27-bookworm (Go 1.27.1) as a static binary (CGO_ENABLED=0) and run on distroless, which has no shell. It builds . when there’s a main.go, and ./... otherwise.
When the go or toolchain line in go.mod asks for a newer Go than that, the Dockerfile sets GOTOOLCHAIN=auto, and the go command fetches the Go it asks for from the Go module proxy and checks it against the checksum database. A line that isn’t a Go release, like go latest, stops the deploy and says so.
go.mod goes into the image on its own for go mod download, so the download is cached until your dependencies change. When go.mod replaces a module with a directory, such as replace jiayang.cloud/sdk => ./sdk, the whole app goes in first instead, so the download finds it. The directory has to be inside the app: the build can’t see anything outside it.
Built with rust:1.92-slim-bookworm using cargo build --release --locked, and run on debian:bookworm-slim with CA certificates. The binary is the package’s name in Cargo.toml.
--locked builds the versions in Cargo.lock, so the app needs one beside its Cargo.toml. Without it the deploy stops before building: run cargo generate-lockfile, or any cargo build, and keep the file with the code.
Files a Go or Rust server reads
Section titled “Files a Go or Rust server reads”The image for a Go or Rust app has the project’s files in /app, which is the working directory, with the binary at /app/server. A server that opens templates/index.html or serves ./assets finds them where it would on your machine. The files are what the build copies, so never target, .git, node_modules or anything else in the ignore list or your .dockerignore.
When the project has its own server at the top, such as a server/ package, the binary goes to /usr/local/bin/server instead and the image starts that.
The start command
Section titled “The start command”container.start in jiayang.json replaces the runtime’s start command, for a Dockerfile jiayang writes:
{ "version": 1, "container": { "start": "uvicorn api.main:app --host 0.0.0.0 --port $PORT" } }It has to be one line. A plain program and its arguments run as they are. A command with shell syntax, such as $PORT, quotes, pipes or &&, runs through sh -c. Go’s image has no shell, so there it has to be a plain command:
error: the start command needs a shell, and this runtime's image hasn't got one. Write it as a command and its arguments, or add a Dockerfile of your own with `jiayang dockerfile --write`.Port and size
Section titled “Port and size”| Default | Flag, for one deploy | jiayang.json | |
|---|---|---|---|
| Port | 8080 | --port |
container.port |
| Size | basic |
--instance-type |
container.instance_type |
The container is started with PORT set to the port. The sizes are lite, basic, standard-1, standard-2, standard-3 and standard-4: Cloudflare Containers’ instance types, smallest first. Each plan runs some of them, and a set number of container apps, each one container: see Limits.
Team and Business run lite and basic, and basic is the default on both. A size the plan doesn’t run is refused before anything is built. jiayang workspace show lists what the workspace’s plan runs:
container apps: 1, at lite or basicEach deploy says what it runs with. A deploy that doesn’t give a port or a size, by flag or in jiayang.json, keeps the one the live version has, and says so:
It listens on port 3000, kept from the version that was live. It runs at size lite.So --port 3000 once sticks until something else sets the port. Put it in jiayang.json to make it plain to everyone who deploys the app.
What never reaches the image
Section titled “What never reaches the image”For a Dockerfile jiayang writes, these are added to your .dockerignore:
.gitnode_modules.venvvenv__pycache__target.env.env.**.pem*.key.dev.varsDockerfile.dockerignoreBefore building, jiayang deploy looks at what the build would copy, after the ignore file has had its say. A file named like a credential stops it, and the error names each one:
error: these look like credentials and the build would copy them into the image: config/tls.key, id_rsa. The app shouldn't hold them: store them with `jiayang secret set`, then delete the files or list them in .dockerignoreA pattern with no slash only matches at the top of the build context, so .env in .dockerignore leaves out ./.env and not config/.env.
How it runs
Section titled “How it runs”- No way in except the edge. The container has no public address. Requests reach it through the platform’s sign-in, at the app’s own URL.
- No direct internet. Outbound requests go through the platform’s egress proxy, which adds your secrets. HTTPS is intercepted there, and
SSL_CERT_FILE,REQUESTS_CA_BUNDLEandNODE_EXTRA_CA_CERTSpoint at the certificate that makes this work, so common HTTP clients need no change. - Environment. The container gets the app’s environment variables, and then
PORT,JIAYANG_APP_ID,JIAYANG_IDENTITY_ISSUERandJIAYANG_JWKS_URL, which your values can’t replace. When a variable changes, the next request stops the running container and starts it again with the new values. - One instance. Every request to the app goes to the same container.
- Sleep. A container with no requests for 10 minutes is stopped. The next request starts it again.
- No database. The platform’s database is for Worker apps.
Each request carries a signed identity token in X-Jiayang-Identity. The SDKs verify it in Node.js, Python, Go and Rust.
Files a browser can keep
Section titled “Files a browser can keep”The edge marks every response private and keeps the max-age your server sends. A server that serves a front end’s hashed files, like index-4f9a1c2e.js, can let browsers keep them for a year, so a return visit doesn’t ask for each one again. In Express:
app.use("/assets", express.static("dist/assets", { immutable: true, maxAge: "1y" }));Serve HTML and anything without a hash in its name as you do now. Static sites has why, and what a browser keeps after someone’s access ends.
Pushing the image
Section titled “Pushing the image”The push goes to the platform’s registry with a push token made for this deploy. The token works for 15 minutes, for this app’s repository, and only while you can deploy the app.
Pushes count against two budgets, each over the last 24 hours. The workspace’s is 20 GB on Team and 100 GB on Business. Yours is 20 GB, counted across every workspace you push to. On Business, one person runs out well before the workspace does. When either is spent:
error: couldn't upload a layer: your push budget for the day is spent; it frees up as the last 24 hours' pushes age outThe version is pinned to the image’s digest, so a later push can’t change what a version runs.
- Environment variables and secrets for configuration and API keys.
- Versions and rollback to put an earlier image back.
- Local development to run the server behind a local copy of the front door.