Containers
A container app is a long-running server, such as a Node server, a Python app, a Go or Rust binary, or anything with a Dockerfile. forculus deploy builds the image on your machine, pushes it to us, and runs it on Cloudflare Containers. People reach it only through our 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 package.json with a server or a start script, a Bun server that calls Bun.serve, deno.json, 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 regardless of what the directory holds, pass --kind container when you create it:
forculus deploy acme/api ./api --create --kind containerAn app can’t switch between a container and a Worker after it’s created.
Container apps need a paid plan. On Free, the deploy stops before it builds anything:
error: The Free plan only runs JS and static apps. Container apps, like Python, Go, Rust and Node servers, need a paid plan. Team runs container apps. Upgrade the workspace at https://app.forculuslabs.tech/w/acme/billingWhat you need
Section titled “What you need”Docker must be running on your machine. The image is built with docker build --platform linux/amd64, the architecture Cloudflare Containers run. To use another program with the same commands, set FORCULUS_DOCKER to it.
An image built for another architecture is refused before it’s pushed. The deploy looks at the programs in the image, not only its config, because a FROM pinned to another platform still builds an image whose config says linux/amd64:
error: The image was built for linux/arm64, but we run linux/amd64. Check whether the Dockerfile's FROM is pinned to another architecture.Deploy one
Section titled “Deploy one”forculus deploy acme/api . --createa container (Express) because package.json depends on express, runs as a node container start: npm startBuilding . for linux/amd64 ...Scanning the image ...Pushing sha256:9e2d71c04af3 (1 MB) ...Pushing sha256:4b1e0f9d2c7a (29 MB) ...Pushing sha256:c81f5a0e36d9 (41 MB) ...Image uploaded. Waiting for it to go live ...Version 1 is going live. Waiting for it ...It listens on port 8080 and runs at size basic.Version 1 (7c3a91e0d5f2) is live.https://api--acme.forculuslabs-apps.techScan: nothing found (64 packages checked).A port: line appears under start: only when forculus.json sets a port. The image is scanned before it’s pushed, and the Scan: line at the end says what was found. For each minute a version is still going live, the deploy says so, like Version 1 is still going live (60 seconds so far) .... Layers we already have aren’t pushed again. If the registry fails on a layer, the deploy retries it a few times before giving up. Once the image is pushed, the deploy removes it from your Docker, unless it was there before the build. Docker’s build cache stays, so the next build is as quick.
While the container starts, the first requests can get a 503 that says The app is starting. Try again in a few seconds. A browser gets a page with the same message, which reloads itself until the app answers. After an app’s first deploy, this can last several minutes. See Troubleshooting.
If the container keeps failing to start, the 503 says The app couldn't start. instead. That happens once it has exited 3 starts in a row, or its starts have kept failing for 10 minutes. A new deploy counts afresh, so it gets The app is starting. while its container starts. Every request still tries to start the container, so a deploy that fixes it brings the app back. An app with more than one container counts for each container on its own, so for a short while some requests can still say it’s starting. forculus logs acme/api --platform shows why. See Troubleshooting.
If the app doesn’t come up, or returns a 502 with The app didn't answer., it has most likely exited. forculus logs acme/api shows what it wrote to stdout and stderr. Every line of it reads as info, so leave out --level. Levels in a container app explains why.
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. The previous version answers requests until it stops, and the deploy ends by telling you so:
Image uploaded. Waiting for it to go live ...Version 2 is going live. Waiting for it ...It listens on port 8080 and runs at size basic, both kept from the previous version.Version 2 (b42d07e1c9aa) is live.https://api--acme.forculuslabs-apps.techScan: nothing found (64 packages checked).Version 1 keeps answering until its container stops and this one's starts. Cloudflare sends it SIGTERM and gives it up to 15 minutes to exit, so the switch takes about as long as your app takes to exit on SIGTERM.Requests that come in between wait for the new container to start. A browser loading a page waits 3 seconds at most, then gets a page saying the app is starting, which reloads itself. A GET or HEAD the old container stopped under is sent to the new one.
A change to the app’s environment variables starts no rollout, since the image stays the same. It takes about five minutes to arrive, busy or idle. Cloudflare first moves the container’s Durable Object onto the new configuration, and until then the running container keeps answering with the old values. Then it’s stopped, with SIGTERM, then SIGKILL if it hasn’t exited within two seconds, and a new container starts with the new values.
If an earlier deploy, rollback or change failed or was interrupted, the change also rolls out the live version’s image again, in case the app was left set to run a different one.
A change made while a deploy is still going waits for it first, and an interrupted deploy counts as still going until it has been quiet for two minutes. Cloudflare counts a rollout as done once the new version is set to start, which can be while the previous version’s container is still exiting. A change made then doesn’t wait for that container. It stops it the way any change does, with two seconds after SIGTERM, and the new version starts with the new values.
An app that exits as soon as it gets SIGTERM is replaced quickly. An app that ignores it keeps the old version running for the full 15 minutes, and a shell entrypoint often ignores it. 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, as the Dockerfile forculus writes for you does.
With your own Dockerfile
Section titled “With your own Dockerfile”Your Dockerfile is used unchanged, with the app’s directory as the build context. Your server must listen on every interface (0.0.0.0) at the port in PORT, which is 8080 unless you set another.
FROM node:24-alpineWORKDIR /appCOPY . .USER nodeCMD ["node", "server.mjs"]The build reads your .dockerignore, or Dockerfile.dockerignore if there is one.
Without a Dockerfile
Section titled “Without a Dockerfile”forculus deploy writes a Dockerfile for the project’s runtime and builds with it from a scratch directory, without changing your project. forculus dockerfile prints the one it would use:
forculus dockerfile# Written by `forculus deploy`. To keep it and edit it yourself, run# `forculus dockerfile --write`. 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"]Only a project that deploys as a container gets one. For a project that deploys as a Worker or a static site, forculus dockerfile says so and prints nothing. To build it as a container anyway, add --kind container here and to forculus deploy.
--write saves it in the project with a .dockerignore. From then on the app builds from your copy:
forculus dockerfile --writeWrote ./Dockerfile and ./.dockerignore.Every Dockerfile forculus writes pins its base images by digest, sets PORT=8080, and runs its last stage as a user other than root. None of them takes a build argument or mounts a secret. Each starts the app under tini, so the app isn’t process 1. SIGTERM then reaches it even if it has no handler, and a rollout doesn’t wait the full 15 minutes.
The runtime comes from the project’s files. package.json means Node, or Bun for a Bun project, deno.json means Deno, a Python dependency file means Python, go.mod means Go and Cargo.toml means Rust. To choose it yourself, set container.runtime in forculus.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.
Without a start script, it starts what the framework built: node build for SvelteKit with @sveltejs/adapter-node, node .output/server/index.mjs for Nuxt, and npx next start for Next.js.
Otherwise it runs the file package.json names as main, or the first of server.js, index.js, app.js and main.js (or their .mjs). With none of those, the deploy stops before building:
error: We can't tell how this app starts. package.json has no start script, and there's no server.js, index.js, app.js or main.js to run. Add a start script, or set the command in forculus.json's container.start.Nuxt can also run as a Worker: build it with Nitro’s cloudflare_module preset and add a wrangler.jsonc. See Workers and frameworks.
Built and run on oven/bun:1.3-slim (Bun 1.3.14). The install is bun install --frozen-lockfile with bun.lock or bun.lockb, and bun install without one. Then bun run build if package.json has a build script. It runs as the bun user and starts with bun run start.
Without a start script, it starts what the framework built: bun build/index.js for SvelteKit with @sveltejs/adapter-node, bun .output/server/index.mjs for Nuxt, and bunx next start for Next.js.
Otherwise it runs bun and the file package.json names as module or main, or the first of index.ts, src/index.ts, server.ts, src/server.ts, index.js and src/index.js. With none of those, the deploy stops before building.
A project with no start script is a Bun container when that file calls Bun.serve. Otherwise its TypeScript entry makes it a Worker, as for what bun init writes before you add a server. To run it on Bun anyway, deploy with --kind container, or set "kind": "container" in forculus.json.
One stage on denoland/deno:debian-2.9.7. It copies the project, runs deno install to fetch its dependencies into the image, and then deno task build if deno.json has a build task. It runs as the deno user and starts with deno task start.
Without a start task it takes the first of main.ts, main.js, server.ts and mod.ts, and starts it with deno serve -A when it has a default export and doesn’t call Deno.serve itself, the way deno init --serve writes one, or with deno run -A otherwise.
-A grants every permission inside the container. We still decide what the app can reach outside it.
Deno.serve() and deno serve listen on port 8000 unless told otherwise, and don’t read PORT. So the image sets DENO_SERVE_ADDRESS to tcp:0.0.0.0: and the port in PORT as it starts, unless the app’s environment sets it. That address wins over a port named in the code. deno.json and deno.jsonc are read the way Deno reads them, comments and trailing commas included.
Python
Section titled “Python”One stage on python:3.13-slim, or on python:3.11-slim or python:3.12-slim when .python-version or runtime.txt asks for 3.11 or 3.12. Any other version gets 3.13, and forculus detect and the deploy say so:
python: 3.13, since runtime.txt asks for python-3.10.2 and we only offer 3.11, 3.12, 3.13The 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. uv installs into /app/.venv, which comes first on PATH, using the image’s Python rather than downloading one.
Poetry installs into the image’s Python rather than a virtualenv of its own. The lockfile or requirements.txt goes into the image and installs before the rest of the project, so a deploy that only changes your code doesn’t install or push the packages again.
That only works when the install reads nothing else. So for pip install ., or a dependency on a path, a local wheel or archive, a --find-links folder or another requirements file, the whole project is copied in first. 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.
If the deploy can’t tell how the app starts, it stops and asks:
error: There's no main.py or app.py to run, and we can't tell how this starts. Set the command in forculus.json's container.start.Django
Section titled “Django”Requests reach the app with its own hostname, <app>--<workspace>.forculuslabs-apps.tech, over https. Django refuses a host that isn’t in ALLOWED_HOSTS with a 400, and a form post from an origin not in CSRF_TRUSTED_ORIGINS with a 403, so add the apps domain to both in settings.py:
ALLOWED_HOSTS = [".forculuslabs-apps.tech"]CSRF_TRUSTED_ORIGINS = ["https://*.forculuslabs-apps.tech"]SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")We set X-Forwarded-Proto ourselves, so the last line is safe to trust.
The image doesn’t run your migrations. A container’s disk goes back to what the image holds every time the container starts: after 10 minutes with no requests, when a variable changes, and with each new version. A SQLite file there loses its data each time. Point Django at a database that lives elsewhere, and run python manage.py migrate against it before you deploy, or at start with container.start:
{ "version": 1, "container": { "start": "python manage.py migrate --noinput && gunicorn mysite.wsgi:application --bind 0.0.0.0:$PORT" } }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 is copied into the image by itself for go mod download, so the download stays cached until your dependencies change. When go.mod replaces a module with a directory, such as replace forculuslabs.tech/sdk => ./sdk, the whole app is copied first instead, so the download can find it. The directory must be inside the app, because 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 at the same relative paths as on your machine. Only files the build copies are there, so target, .git, node_modules and anything else in the ignore list or your .dockerignore are left out.
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 forculus.json replaces the runtime’s start command, for a Dockerfile forculus writes:
{ "version": 1, "container": { "start": "uvicorn api.main:app --host 0.0.0.0 --port $PORT" } }It must be one line. A plain program and its arguments run directly. A command with shell syntax, such as $PORT, quotes, pipes or &&, runs through sh -c. Go’s image has no shell, so a Go start command can’t use shell syntax:
error: The start command needs a shell, and this runtime's image doesn't have one. Write it as a command and its arguments, or add your own Dockerfile with `forculus dockerfile --write`.Port and size
Section titled “Port and size”| Default | Flag, for one deploy | forculus.json | |
|---|---|---|---|
| Port | 8080 | --port |
container.port |
| Size | basic |
--instance-type |
container.instance_type |
| Containers | 1 | --instances |
container.instances |
forculus detect shows what forculus.json sets, as port, size and containers. forculus deploy --dry-run shows the same lines with its flags in place of the file’s. The flags are only for container apps. On a Worker or a static site, the deploy stops before anything is built or uploaded:
error: --instances is only for container apps, and this is a Worker. Leave it out, or add --kind container if the app is a container.The container starts with PORT set to the port. The sizes are Cloudflare Containers’ instance types, smallest first:
| Size | vCPU | Memory | Disk |
|---|---|---|---|
lite |
1/16 | 256 MiB | 2 GB |
basic |
1/4 | 1 GiB | 4 GB |
standard-1 |
1/2 | 4 GiB | 8 GB |
standard-2 |
1 | 6 GiB | 12 GB |
standard-3 |
2 | 8 GiB | 16 GB |
standard-4 |
4 | 12 GiB | 20 GB |
A deploy says the port and size it went live with. Both belong to the version, so forculus versions --json gives each version’s port and instance_type, while forculus app show gives only the number of containers, which belongs to the app.
Each plan runs some of these sizes, a set number of container apps, a set number of containers in each app, and a set number of container units for all of them together. See Limits.
Team runs one container app with one container, at lite or basic. Business runs four container apps at sizes up to standard-1, and an app can run up to four containers. basic is the default on both. What the plan doesn’t run is refused before anything is built. forculus workspace show lists what the workspace’s plan runs, and to owners and admins, and members whose role lets them see usage, how many container units are in use:
container apps: 4, at lite, basic or standard-1containers per app: up to 4container units: 34 of 40 in use, where a container at lite is 4, basic 10 and standard-1 24Each deploy prints the port and size it uses. If neither a flag nor forculus.json gives a port or size, the deploy keeps the live version’s, and says so:
It listens on port 3000, kept from the previous version. It runs at size lite.So after one deploy with --port 3000, the app stays on port 3000 until something else sets the port. Put the port in forculus.json so everyone who deploys the app can see it.
More than one container
Section titled “More than one container”An app can run more than one container of the same image, up to what the plan allows. --instances, container.instances in forculus.json and forculus scale need CLI 0.1.12 or later. An older CLI stops at a forculus.json with container.instances in it, so upgrade every CLI that deploys the app before you add it, the GitHub deploy action’s included. Each request goes to one of them, picked at random among those that are up, so a busy app can serve more at once and a request doesn’t wait for a container still starting while another one answers. While one is up, the others start only once requests keep coming, so a quiet app keeps one container awake rather than all of them. A WebSocket stays on the container it opened on. Running fewer, the containers past the new number stop within a few minutes, and any WebSocket still open on one of them closes, so your client should reconnect.
Set how many when you deploy:
forculus deploy acme/api . --instances 3It listens on port 8080 and runs at size basic. It runs 3 containers, and each request goes to one of them.Or change it without a deploy. The version live stays live, the containers running keep running, and nothing rolls out:
forculus scale acme/api 2acme/api runs 2 containers. Each request goes to one of them, so keep what a request needs in the database or app files, not in the container.There are two exceptions. If the last deploy, rollback or change didn’t finish, the app may have been left set to run another image, so the scale rolls the live version out again, as a deploy would, and says so:
The live version rolled out again, because the last deploy didn't finish.And a change to the app’s environment variables that hasn’t gone live yet goes live with the scale: each container restarts with the new values on its next request.
The number belongs to the app, not to a version, so a rollback or a deploy that doesn’t give one keeps it. A new app runs one. forculus app show gives it, as containers: 2, and so does the app’s page in the dashboard.
Requests don’t stick to a container. Anything a request needs from the one before, like a session or an upload in progress, has to be in the app’s database or files, or in the signed-in person’s identity, not in the container’s memory or disk. Each container starts on its first request, stops after 10 minutes with none, and starts again when one comes.
Container units
Section titled “Container units”A plan’s container apps share its container units. Each container uses units by its size, so an app uses its size’s units times its containers:
| Size | Units |
|---|---|
lite |
4 |
basic |
10 |
standard-1 |
24 |
standard-2 |
41 |
standard-3 |
68 |
standard-4 |
121 |
Team has 10 units: its one basic container. Business has 40, which it can spend as four basic apps, one app on four basic containers, or a standard-1 app beside a basic one and a lite one. A deploy, rollback or forculus scale that would take the workspace past its units is refused before anything changes:
error: Running 2 containers at basic takes 20 container units. With the 24 the workspace's other container apps use, that's past the 40 the Business plan allows. Run fewer or smaller containers. Write to sales@forculuslabs.tech to run more. Billing: https://app.forculuslabs.tech/w/acme/billingforculus workspace usage says how many are in use too, as Container units: 34 of 40 in use., and so does the containers field of its --json.
Redeploying an app at what it uses already is never refused for units, so a workspace that has moved to a smaller plan keeps deploying what it runs. Only growing waits for room. A version still going live counts at its own size, so two apps growing at once can’t both take the same units. An app that runs fewer containers than it did keeps their units until Cloudflare says the ones it stopped are gone, and for at least 15 minutes after the change, so other apps can only take them after that. If the change doesn’t go live, as when a version is going live at the time, the app keeps them until you run the same command again and it does. A container app in the bin uses none, and restoring it needs room for what it ran. It comes back as it went, so make the room with the other apps first:
error: Restoring api runs 1 container at basic, which takes 10 container units. With the 34 the workspace's other container apps use, that's past the 40 the Business plan allows. Scale another container app down with `forculus scale`, or delete one, then restore api again. Write to sales@forculuslabs.tech to run more. Billing: https://app.forculuslabs.tech/w/acme/billingFiles left out of the image
Section titled “Files left out of the image”For a Dockerfile forculus writes, these are added to your .dockerignore:
.gitnode_modules.venvvenv__pycache__target.env.env.**.pem*.key.dev.varsDockerfile.dockerignoreBefore building, forculus deploy checks the files the build would copy. A file named like a credential stops the deploy, and the error lists each one. That includes a .env beside the app, even though the list above leaves it out of the image: the app shouldn’t hold one, as for a Worker or your own Dockerfile. List it in your own .dockerignore to deploy anyway:
error: These look like credentials and the build would copy them into the image: config/tls.key, id_rsa. Apps shouldn't hold credentials. Store them with `forculus secret set`, then delete the files or list them in .dockerignore.A .env at the top is refused too, even though the image leaves it out. Apps shouldn’t hold credentials.
Once the image is built, forculus deploy reads its layers and its config for keys before pushing it, and its lock files for packages with published vulnerabilities. It sends us what it found, never the files. See Deploy scans.
A pattern with no slash matches only at the top of the build context, so .env in .dockerignore leaves out ./.env but not config/.env.
How it runs
Section titled “How it runs”- The container has no public address. Requests reach it only through our sign-in, at the app’s own URL.
- Outbound requests go through our egress proxy, which adds your secrets. The proxy intercepts HTTPS, and
SSL_CERT_FILE,REQUESTS_CA_BUNDLEandNODE_EXTRA_CA_CERTSpoint at its certificate, so common HTTP clients work without changes. The interception goes by the host name the connection asks for, so an HTTPS request to an IP address, likehttps://1.1.1.1/, has its connection reset during the TLS handshake, before anything is sent. Node’s fetch saysECONNRESET, and curl exits with(35) Recv failure: Connection reset by peer. Over plain http, a request to a public IP address is refused by Cloudflare with403and the bodyerror code: 1003, since Workers can’t send a request to an address. Use the host’s name instead. - The container gets the app’s environment variables, plus
PORT,FORCULUS_APP_ID,FORCULUS_IDENTITY_ISSUERandFORCULUS_JWKS_URL, which your values can’t replace. When a variable changes, the container restarts with the new values, about five minutes later. - Each app runs one container unless it asks for more. With more, each request goes to one of them. See More than one container.
- A container with no requests for 10 minutes is stopped. The next request starts it again.
- A container isn’t held to a CPU time per request, as a Worker app is. Its size sets how much CPU it has, and a request can use all of it for as long as the request lasts.
- The container reaches its own database and files over HTTP, at
storage.forculus.internal. They last across restarts and deploys, unlike the container’s disk.
Each request carries a signed identity token in X-Forculus-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. If your server serves a front end’s hashed files, like index-4f9a1c2e.js, it can let browsers cache them for a year, so a return visit doesn’t request each one again. In Express:
app.use("/assets", express.static("dist/assets", { immutable: true, maxAge: "1y" }));Leave caching for HTML and files without a hash in their name as it is. Static sites explains why, and what a browser keeps after someone’s access ends.
Pushing the image
Section titled “Pushing the image”The image is pushed to our registry with a push token made for this deploy. The token lasts 15 minutes, covers only this app’s repository, and stops working if you lose permission to deploy the app. A build can take longer than that: the deploy asks for a new token when the build has left the first one less than 10 minutes, and once more if the registry refuses it partway through the push.
Pushes count against two budgets, each over the last 24 hours. The workspace’s budget is 20 GB on Team and 100 GB on Business. Your own is 20 GB, counted across every workspace you push to, so on Business one person runs out well before the workspace does. When either budget is used up:
error: Couldn't upload a layer: you've used today's push budget. It frees up as pushes from the last 24 hours age out.Each version is pinned to its image’s digest, so a later push can’t change what a version runs.
Next steps
Section titled “Next steps”- 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.
- Deploy a Streamlit, Gradio or FastAPI app that only your team can open, start to finish.