Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

g1t.page runs Docker deployments, behind a switch that is off until Cloudflare's container APIs are checked against g1t's account (`CONTAINERS` in the deployments service). Each container app is a Worker of g1t's own in the same Workers for Platforms namespace, so the dispatcher, custom domains, previews and take-downs reach it by name as any app: its `G1tApp` Durable Object holds a Container application (default scheduling policy, one instance, the project's **Container size**, `lite` unless chosen), starts it on the first request with the project's secrets and variables and `PORT` as its environment, waits up to 90 seconds for it to answer on its port, proxies to it, and stops it five minutes after the last request; it charges each minute awake ahead and the bytes it sends, and reports them with a token of its own to `POST /deployments/apps/<app>/meter`, which the API forwards. A build's new `registry` step gets a registry login g1t mints push-only for 15 minutes, with its app's own repository named; g1t runs only an image by digest in that repository, checked with the registry, and never hands a build a login that can pull. Container time is metered at Cloudflare's Containers price plus the margin through new price book meters (vCPU charged as busy while awake, and egress at the most a GB costs anywhere, so g1t never sells below cost), noted for the spend limit as it runs and charged with the month's Deployments line; a paused workspace's container apps stop and start no more. Settings, Deployments gets Build as (Auto, Static site, Workers, Docker), Dockerfile and Container size with each size's price an hour, and says what the last build found with what it was built with ("Static site · Astro", "Container · Dockerfile"), now that the finish report's `framework` is stored. Deploy sandboxes get Docker, `BUILD_AS` and `DOCKERFILE`, and reach `registry.cloudflare.com`; `start_deployment_build` answers `build_as` and `dockerfile` for `g1t/deploy`, which takes them as inputs, as `g1t/detect` does. The Deployments guide has Docker deployments, their prices and how they work with the evidence for it, the limitations page says what is left, and Deploy g1t to Cloudflare lists each live check to make before switching it on.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentae80418Browse files
34 files+2087−1122 files not counted0/34 viewed
+2−0
4141 | `root-dir` | The project's setting | Where the project is in the checkout. |
4242 | `build-command` | The project's setting, else what the build finds | What builds it. |
4343 | `output-dir` | The project's setting, else the folder the framework writes, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
44+| `build-as` | The project's setting, else `auto` | What it is built as: `auto`, `static`, `workers` or `docker`. A container is built with the `docker` on the job's `PATH`. |
45+| `dockerfile` | The project's setting, else `Dockerfile` or `Containerfile` | The Dockerfile a container is built from, relative to the project. |
4446 | `sha` | The run's commit | The commit this is a build of, for its `g1t / deploy` check. |
4547 | `token` | `${{ github.token }}` | The job's own token. It needs `deployments: write`; any other token is refused. |
4648
+10−0
1919 description: The folder a static build writes. The project's own setting, else the folder the framework writes, else the first of dist, build, out, public, _site or .output/public.
2020 required: false
2121 default: ""
22+ build-as:
23+ description: What it is built as, auto, static, workers or docker. The project's own setting unless you say; auto finds it from the project's files.
24+ required: false
25+ default: ""
26+ dockerfile:
27+ description: The Dockerfile a container is built from, relative to the project. The project's setting, else Dockerfile or Containerfile.
28+ required: false
29+ default: ""
2230 sha:
2331 description: The commit this is a build of, for the commit's g1t / deploy check. The run's commit unless you say.
2432 required: false
4957 INPUT_ROOT_DIR: ${{ inputs.root-dir }}
5058 INPUT_BUILD_COMMAND: ${{ inputs.build-command }}
5159 INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
60+ INPUT_BUILD_AS: ${{ inputs.build-as }}
61+ INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
5262 INPUT_SHA: ${{ inputs.sha }}
5363 INPUT_TOKEN: ${{ inputs.token }}
5464 run: bash "$GITHUB_ACTION_PATH/deploy.sh"
+2−0
9393 export ROOT_DIR="$(pick "${INPUT_ROOT_DIR:-}" root_dir)"
9494 export BUILD_COMMAND="$(pick "${INPUT_BUILD_COMMAND:-}" build_command)"
9595 export OUTPUT_DIR="$(pick "${INPUT_OUTPUT_DIR:-}" output_dir)"
96+export BUILD_AS="$(pick "${INPUT_BUILD_AS:-}" build_as)"
97+export DOCKERFILE="$(pick "${INPUT_DOCKERFILE:-}" dockerfile)"
9698 export BUILD_ENV="$(field build_env <<<"${answer}")"
9799 export BUILD_SECRETS="$(field build_secrets <<<"${answer}")"
98100 unset GIT_REMOTE
+9−5
2424 | `root-dir` | The checkout | Where the project is in the checkout. |
2525 | `build-command` | None | A build command of your own, as `g1t/deploy` takes it. |
2626 | `output-dir` | None | An output folder of your own, as `g1t/deploy` takes it. |
27+| `build-as` | `auto` | What it is built as: `auto`, `static`, `workers` or `docker`, as `g1t/deploy` takes it. |
28+| `dockerfile` | None | A Dockerfile's path of your own, as `g1t/deploy` takes it. |
2729
2830 ## Outputs
2931
3032 | Output | |
3133 | --- | --- |
32−| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, or a framework's Cloudflare adapter), `static` (a site a build writes), `html` (the files as they are) or `none` (nothing to serve, or a framework set up to need a server). |
33−| `framework` | The framework it is built with, from the table below. Empty when there is none. |
34+| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, or a framework's Cloudflare adapter), `static` (a site a build writes), `html` (the files as they are), `container` (a `Dockerfile`, a framework set up to need a server, or a server g1t writes a Dockerfile for) or `none` (nothing to serve). |
35+| `framework` | The framework it is built with, from the table below; for a container without one, `dockerfile`, or the server g1t writes a Dockerfile for (`node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, `rack`, `laravel`, `php`). Empty when there is none. |
36+| `dockerfile` | A container's own Dockerfile, relative to the project. Empty when g1t writes one, and for anything that is not a container. |
37+| `port` | The port a container listens on: its Dockerfile's `EXPOSE`, else `8080`. |
3438 | `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on, and `bundle install` for Jekyll. Empty when there is nothing to install. |
3539 | `build` | What builds it: your command, the package's `build` script (`npm run build`), the framework's own command, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
3640 | `output-dir` | The folder served, relative to the project (`.` for the project itself): yours, a Workers project's assets directory, the folder the framework writes, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it, as for a Cloudflare adapter's Worker. |
3741
38−When `kind` is `none` because of how a framework is set up, such as a
39−Next.js app without `output: 'export'`, the step's log says what to
40−change.
42+When `kind` is `none`, the step's log says what to change or add, such as
43+a `Dockerfile`. [Docker deployments](https://docs.g1t.sh/guides/deployments/#docker-deployments)
44+has the order in which a project's kind is decided.
4145
4246 ## Frameworks
4347
+18−2
1515 description: An output folder of your own, as g1t/deploy takes it.
1616 required: false
1717 default: ""
18+ build-as:
19+ description: What it is built as, auto, static, workers or docker, as g1t/deploy takes it.
20+ required: false
21+ default: ""
22+ dockerfile:
23+ description: A Dockerfile's path of your own, as g1t/deploy takes it.
24+ required: false
25+ default: ""
1826
1927 outputs:
2028 kind:
21− description: workers (a Wrangler config, or a framework's Cloudflare adapter), static (a site a build writes), html (the files as they are) or none (nothing to serve, or a framework set up to need a server).
29+ description: workers (a Wrangler config, or a framework's Cloudflare adapter), static (a site a build writes), html (the files as they are), container (a Dockerfile, a framework set up to need a server, or a server g1t writes a Dockerfile for) or none (nothing to serve).
2230 value: ${{ steps.detect.outputs.kind }}
2331 install:
2432 description: How its dependencies are installed, such as npm ci. Empty without a package.json.
3038 description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it.
3139 value: ${{ steps.detect.outputs.output-dir }}
3240 framework:
33− description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo. Empty when there is none.
41+ description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo; for a container without one, dockerfile or the server g1t writes a Dockerfile for (node, flask, rails…). Empty when there is none.
3442 value: ${{ steps.detect.outputs.framework }}
43+ dockerfile:
44+ description: A container's own Dockerfile, relative to the project. Empty when g1t writes one, or for anything else.
45+ value: ${{ steps.detect.outputs.dockerfile }}
46+ port:
47+ description: The port a container listens on, its Dockerfile's EXPOSE or 8080. Empty for anything else.
48+ value: ${{ steps.detect.outputs.port }}
3549
3650 runs:
3751 using: composite
4256 ROOT_DIR: ${{ inputs.root-dir }}
4357 BUILD_COMMAND: ${{ inputs.build-command }}
4458 OUTPUT_DIR: ${{ inputs.output-dir }}
59+ BUILD_AS: ${{ inputs.build-as }}
60+ DOCKERFILE: ${{ inputs.dockerfile }}
4561 run: |
4662 set -euo pipefail
4763 runner="$(bash "$GITHUB_ACTION_PATH/runner.sh")"
+1−1
8181 DeploymentsOp::CreateDeploymentStatus => "Add a status to a reported deployment: state (queued, in_progress, success, failure, error or inactive), description, environment_url (where it is served) and log_url (where its output can be read). The deployment takes its state, and any address it gives. A success with auto_inactive (true unless you say) makes the environment's older successful deployments inactive. The commit's `deploy / <environment>` check follows: pending while queued or in progress, then success, failure or error. A g1t.page build's statuses come from the build and cannot be added to. Needs the Write role.",
8282 DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed, and after them those with protection rules but no deployment yet. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at, and, when it has rules, protection_rules (required_reviewers, wait_timer, branch_policy), deployment_branch_policy, branch_policies and can_admins_bypass. total_count counts deployments across every environment. Needs the Read role.",
8383 DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments and its protection rules (see update_environment). An environment with rules but no deployment yet is found too. A name with slashes is URL-encoded in the path. Needs the Read role.",
84− DeploymentsOp::StartDeploymentBuild => "From a running g1t Actions job, open a g1t.page build of one of the repository's projects, which the job then builds in its own checkout with `g1t-runner deploy` (the g1t/deploy action does both). Only the job's own token (G1T_TOKEN) may call it, with `deployments: write` (or `pages: write`) in its `permissions:`. project is the project's slug; it may be left out when the repository has one project with Deployments on. The run decides what is built: a run on the project's production branch (a push, a dispatch or a schedule there) builds production, a pull request's run builds that pull request's preview, and a run on another branch builds that branch's preview; a tag's run is refused. sha is the commit built, the run's unless you say. A pull request from a fork or from someone without the Write role, and a run that had to be approved, get no build secrets. The workspace's plan must include Deployments and its spend limit must not be reached; the build's time is the job's Actions time and is not charged again. Returns deploy_id and token (DEPLOY_ID and DEPLOY_TOKEN for `g1t-runner deploy`, the token good for this build alone until it finishes), kind (production or preview), project, branch, number, commit, url (the app's address), trusted, the project's root_dir, build_command and output_dir, and build_env and build_secrets (the project's variables and secrets for builds of that kind). The commit's `g1t / deploy` check is pending until the build reports.",
84+ DeploymentsOp::StartDeploymentBuild => "From a running g1t Actions job, open a g1t.page build of one of the repository's projects, which the job then builds in its own checkout with `g1t-runner deploy` (the g1t/deploy action does both). Only the job's own token (G1T_TOKEN) may call it, with `deployments: write` (or `pages: write`) in its `permissions:`. project is the project's slug; it may be left out when the repository has one project with Deployments on. The run decides what is built: a run on the project's production branch (a push, a dispatch or a schedule there) builds production, a pull request's run builds that pull request's preview, and a run on another branch builds that branch's preview; a tag's run is refused. sha is the commit built, the run's unless you say. A pull request from a fork or from someone without the Write role, and a run that had to be approved, get no build secrets. The workspace's plan must include Deployments and its spend limit must not be reached; the build's time is the job's Actions time and is not charged again. Returns deploy_id and token (DEPLOY_ID and DEPLOY_TOKEN for `g1t-runner deploy`, the token good for this build alone until it finishes), kind (production or preview), project, branch, number, commit, url (the app's address), trusted, the project's root_dir, build_command and output_dir, build_as (auto, static, workers or docker) and dockerfile (its Dockerfile's path, or null to find `Dockerfile` or `Containerfile`), and build_env and build_secrets (the project's variables and secrets for builds of that kind). The commit's `g1t / deploy` check is pending until the build reports.",
8585 }
8686 }
8787
+8−4
548548 }
549549
550550 // A sandbox building a deployment, reporting with its build's token,
551− // which is not a g1t token. The body goes through as it is: it can
552− // carry a Worker's bundled code.
551+ // which is not a g1t token, or a container app's Worker reporting how
552+ // long its container ran, with its own token. The body goes through as
553+ // it is: it can carry a Worker's bundled code.
553554 if method == "POST" && !on_mcp
554− && let Some(rest) = path.strip_prefix("/deployments/jobs/")
555+ && let Some(rest) = path
556+ .strip_prefix("/deployments/jobs/")
557+ .map(|rest| format!("jobs/{rest}"))
558+ .or_else(|| path.strip_prefix("/deployments/apps/").filter(|rest| rest.ends_with("/meter")).map(|rest| format!("apps/{rest}")))
555559 {
556− let target = format!("https://deployments/jobs/{rest}");
560+ let target = format!("https://deployments/{rest}");
557561 let body = request.bytes().await?;
558562 let headers = worker::Headers::new();
559563 headers.set("content-type", "application/json")?;
+2−0
1186611866 "root_dir": "",
1186711867 "build_command": null,
1186811868 "output_dir": null,
11869+ "build_as": "auto",
11870+ "dockerfile": null,
1186911871 "build_env": {
1187011872 "PUBLIC_API_URL": "https://api.example.com"
1187111873 },
+29−9
359359
360360 ## Deployments
361361
362−### Static sites and Workers only
362+### Containers are not switched on yet
363363
364−Deployments run static sites and Workers projects. You can't deploy a
365−long-running server, a container, or an app that needs a process that stays
366−up.
364+[Docker deployments](/guides/deployments/#docker-deployments) are built,
365+but off on g1t.page: a project that runs as a container fails to deploy,
366+saying so, and one with a site besides its `Dockerfile` is built as that
367+site.
367368
368−- **Why.** Apps run on Cloudflare Workers, which run only while they answer a
369− request. That is why an app nobody visits costs nothing.
369+- **Why.** Cloudflare's container APIs (applications in a Workers for
370+ Platforms namespace, registry logins) have to be checked against g1t's
371+ own account first.
370372 - **Instead.** Build a framework app as a static export or with its
371373 Cloudflare adapter, as [Frameworks](/guides/deployments/#frameworks)
372− lists; a build set up to need a server fails and says which. Or deploy
373− the server from a workflow to wherever it runs today, with its
374− credentials in [secrets](/guides/secrets-and-variables/).
374+ lists, or set **Build as** to Static site. Or deploy the server from a
375+ workflow to wherever it runs today, with its credentials in
376+ [secrets](/guides/secrets-and-variables/).
377+
378+### A container is one instance, and keeps nothing
379+
380+A container app runs one instance at a time, sleeps after five minutes
381+without a request, and starts from its image each time it wakes.
382+
383+- **Why.** Each app's container is one Durable Object's, and Cloudflare
384+ Containers' disks last only as long as the container runs.
385+- **Instead.** Keep data in a database or a bucket the app reaches over
386+ the network. A larger **Container size** gives one instance more room.
387+
388+### What a container sends out is not counted
389+
390+The data a container sends back to visitors is metered; what it sends to
391+other hosts on the internet (a database elsewhere, an API) is not.
392+
393+- **Why.** It leaves the container without passing through g1t.
394+- **Instead.** Nothing to do: it is not charged.
375395 - **Status.** A runtime for servers is planned.
376396
377397 ### Some Workers bindings are not provisioned
+58−0
344344 nothing else expires in the bucket. Reset deletes the agent's object at
345345 once.
346346
347+## Docker deployments
348+
349+Container apps ([Docker deployments](/guides/deployments/#docker-deployments))
350+are off until `CONTAINERS` is `on` in `services/deployments/wrangler.jsonc`.
351+The code follows Cloudflare's documentation and Wrangler's own client for
352+these APIs, but none of them has been called against g1t's account yet.
353+Before turning it on, check each of these with a test project:
354+
355+1. **The token.** `CLOUDFLARE_API_TOKEN` of `services/deployments` has
356+ **Containers: Edit** (and the account has Containers, on Workers Paid).
357+2. **Registry logins.**
358+ `POST /accounts/{id}/containers/registries/registry.cloudflare.com/credentials`
359+ with `{ "permissions": ["push"], "expiration_minutes": 15 }` answers
360+ inside the API's usual `{ success, result }` with a `username` and
361+ `password`, and `docker push` succeeds with that login alone, with no
362+ `pull` (Docker checks for layers before it uploads them). If it needs
363+ `pull`, do not add it: a pull login reads every app's image. Find
364+ another way first, such as Cloudflare scoping logins to a repository.
365+3. **The registry's hosts.** A push from a guarded sandbox reaches only
366+ `registry.cloudflare.com` (on the deploy builds' list in
367+ `services/runner/src/egress.ts`). If its layers upload to another host,
368+ add that host there.
369+4. **Checking a digest.** g1t asks
370+ `HEAD https://registry.cloudflare.com/v2/<account>/g1t-<app>/manifests/sha256:…`
371+ with a pull login of its own, following a `WWW-Authenticate: Bearer`
372+ challenge. It answers 200 for an image that was pushed.
373+5. **The app's Worker.** A script uploaded to the dispatch namespace with
374+ `exports: { "G1tApp": { "type": "durable-object", "storage": "sqlite" } }`
375+ and `containers: [{ "class_name": "G1tApp" }]` in its metadata is taken,
376+ and taken again on the next deploy with nothing changed about the class.
377+6. **Its Durable Object namespace.**
378+ `GET /accounts/{id}/workers/durable_objects/namespaces` lists it with
379+ `class` `G1tApp`, `script` the app's name and `dispatch_namespace`
380+ `g1t-deployments`.
381+7. **The Container application.** `POST /accounts/{id}/containers/applications`
382+ with `scheduling_policy` `default`, the image by digest, `instance_type`,
383+ `max_instances` 1 and `durable_objects.namespace_id` creates it; a
384+ rollout (`…/rollouts`, `strategy` `rolling`, `step_percentage` 100,
385+ `kind` `full_auto`) moves it to a new image; `DELETE` removes it.
386+8. **Starting.** `ctx.container.start({ env, enableInternet: true })`
387+ works under the default policy from a Worker in a dispatch namespace,
388+ the container gets its environment, and `getTcpPort(port).fetch()`
389+ reaches it; the dispatcher's limits (50 ms of CPU, 50 subrequests) leave
390+ room for a page that waits on a container starting.
391+9. **Sleeping and billing.** Five minutes after the last request the
392+ container stops (the app's object stops it itself), Cloudflare's billable
393+ usage for Containers stops growing, and `container_meters` in the
394+ deployments database has the minutes the app's Worker reported, which
395+ should be at least what Cloudflare billed for the same time.
396+10. **Pausing and taking down.** A workspace over its limit gets its
397+ container app's Worker put back paused, which stops the container, and
398+ taking an app down deletes its application before its Worker.
399+
400+Then set `CONTAINERS` to `on`, deploy `services/deployments`, and the
401+container build of a project with a `Dockerfile` goes live; the
402+[limitations page](/about/limitations/#containers-are-not-switched-on-yet)
403+and the caution in the deployments guide come out in the same change.
404+
347405 ## Rolling back
348406
349407 - **One unit, at once:** `npx wrangler rollback` in its folder. Revert the
+226−27
1111 deploys nothing while the remote leads it: pushes copied in from the remote
1212 don't build, and its **Deployments** page comes with a takeover.
1313
14−Apps run on Cloudflare Workers, on `g1t.page`:
14+Apps run on Cloudflare Workers, on `g1t.page`, and a project with a
15+`Dockerfile` (or a server g1t can write one for) runs as a container on
16+Cloudflare Containers behind one; see [Docker deployments](#docker-deployments):
1517
1618 | | Address |
1719 | --- | --- |
5052 report your own, and hear of them by webhook.
5153
5254 An app runs only while it answers a request. One nobody visits runs
53−nothing and costs nothing, and the next visit wakes it in milliseconds.
55+nothing and costs nothing, and the next visit wakes it in milliseconds. A
56+container wakes in seconds, and sleeps again after five minutes without a
57+request.
5458
5559 Deployments are part of the [g1t plan](/guides/usage-and-billing/#the-g1t-plan),
5660 and are opt-in per project. A project set to deploy on g1t, with
96100 | --- | --- |
97101 | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. |
98102 | A framework from the table below | Installs dependencies, builds it with the framework's command, and serves the folder it writes as a **static site**, or deploys the Worker its Cloudflare adapter writes as a **Workers project**. |
103+| A framework set up to need a server (Next.js without a static export, Astro's `@astrojs/node`, SvelteKit's `adapter-node`, a server-rendered React Router or Remix app) | Builds it as a **container**: from its `Dockerfile`, or one g1t writes for it. |
104+| A `Dockerfile` or `Containerfile`, and no framework above | Builds the image and runs it as a **container**. |
105+| A Node.js, Python, Go, Ruby or PHP server, with no `Dockerfile` | Writes a Dockerfile for it, shows it in the build's log, and runs it as a **container**. See [Docker deployments](#docker-deployments). |
99106 | A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
100107 | An `index.html` and nothing to build | Serves it as it is. |
108+| Nothing g1t recognises | Fails, saying to add a `Dockerfile`. |
101109
102110 **Settings → Deployments** shows what g1t detected at the project's last
103−finished build (**Workers project**, **Static site** or **Plain HTML**),
104−and what running it costs. Before the first build it says **Detected at
105−the first build**. The framework is named at the top of the build's log,
106−as `Detected Astro: framework=astro.`
111+finished build and what it was built with, such as **Static site ·
112+Astro**, **Workers project**, **Container · Dockerfile** or **Container ·
113+Flask, with a Dockerfile g1t wrote**, and what running it costs. Before
114+the first build it says **Detected at the first build**. The framework is
115+named at the top of the build's log, as `Detected Astro: framework=astro.`
116+**Build as**, under the same settings, overrides what is detected.
107117
108118 Dependencies are installed by the lockfile that is there: `npm ci`,
109119 `pnpm install --frozen-lockfile`, `yarn install` or `bun install`, and
138148 | Jekyll | `_config.yml` and a `Gemfile` | `bundle install`, then `bundle exec jekyll build` | `_site`, or `destination` |
139149 | mdBook | `book.toml` | `mdbook build` | `book`, or `build.build-dir` |
140150
141−- **Frameworks that need a server fail early, saying what to change.**
142− g1t.page runs static sites and Workers, not servers that stay up. A
143− Next.js app without `output: 'export'`, an Astro site with
144− `output: 'server'` and no Cloudflare adapter, a SvelteKit app with
145− `adapter-auto` or `adapter-node`, and a React Router or Remix app that
146− renders on the server each fail before anything is installed, with the
147− change that makes them deploy: a static export, or the framework's
148− Cloudflare adapter. An **Output directory** of your own skips the check.
151+- **Frameworks that need a server run as containers.** A Next.js app
152+ without `output: 'export'`, an Astro site with `@astrojs/node`, a
153+ SvelteKit app with `adapter-node`, and a React Router or Remix app that
154+ renders on the server are built from their `Dockerfile`, or one g1t
155+ writes ([Docker deployments](#docker-deployments)). A static export or
156+ the framework's Cloudflare adapter costs less, since it runs nothing
157+ while no one visits; the build's log says so. One g1t cannot run as a
158+ server, such as SvelteKit with `adapter-auto`, fails before anything is
159+ installed, saying what to change or to add a `Dockerfile`. An **Output
160+ directory** of your own, or **Build as** Static site, builds it as a
161+ static site instead.
149162 - **A framework's Worker.** Astro's and SvelteKit's Cloudflare adapters,
150163 OpenNext, and `@cloudflare/vite-plugin` with a Wrangler config build a
151164 Worker. g1t runs the build, then bundles what it wrote with
192205 lists each one it left out. Code that needs them should check that the
193206 binding is there.
194207
195−What Deployments cannot run yet, such as long-running servers, is on
208+### Docker deployments
209+
210+Any app that runs in a container deploys as one, whatever its framework or
211+age: give it a `Dockerfile`, or let g1t write one. It runs on
212+[Cloudflare Containers](https://developers.cloudflare.com/containers/)
213+behind the app's Worker, at the same addresses, custom domains and
214+previews as any other app.
215+
216+:::caution[Not switched on yet]
217+Docker deployments are built but off on g1t.page until Cloudflare's
218+container APIs are checked against g1t's account. Until then a container
219+build fails with "Docker deployments are not switched on for g1t.page
220+yet", and a project that is a container only for having a `Dockerfile`,
221+beside a site g1t could serve, is built as that site, as before.
222+:::
223+
224+What a project is built as is decided in this order:
225+
226+1. **Build as**, under **Settings → Deployments**: **Static site**,
227+ **Workers** or **Docker** is what it is built as, whatever else it has.
228+2. **Auto** (the default) finds it from its files:
229+ 1. A Wrangler config is a Workers project.
230+ 2. A framework that builds a static site or a Worker is that, even
231+ beside a `Dockerfile`: it costs nothing while no one visits.
232+ 3. A framework set up to need a server is a container, from its
233+ `Dockerfile`, or one g1t writes for it.
234+ 4. A project with no framework g1t knows is a container when it has a
235+ `Dockerfile` (or `Containerfile`, or the path set as its
236+ **Dockerfile**), or is a server g1t recognises.
237+ 5. Otherwise a static site or plain HTML, as above; with nothing to
238+ serve, the build fails, saying to add a `Dockerfile`.
239+
240+g1t writes a Dockerfile for these, when there is none:
241+
242+| The project has | Runs with |
243+| --- | --- |
244+| `package.json` with a `start` script, or a server package (`express`, `fastify`, `koa`, `@nestjs/core`, `@hono/node-server` and others) and a main file | `npm start`, or `node <main>`, on `node:22-slim` (or the major release in `.nvmrc` or `.node-version`), after `npm run build` when there is one |
245+| Next.js, Astro with `@astrojs/node`, SvelteKit with `adapter-node`, React Router or Remix | `next start`, `node ./dist/server/entry.mjs`, `node build`, `react-router-serve` or `remix-serve`, after the build |
246+| `requirements.txt`, `pyproject.toml` or `Pipfile`, and `manage.py` (Django) | `gunicorn <project>.wsgi` on `python:3.12-slim` |
247+| The same, with Flask and an `app.py`, `main.py`, `server.py`, `wsgi.py` or `asgi.py` | `gunicorn <module>:app` |
248+| The same, with FastAPI or Starlette | `uvicorn <module>:app` |
249+| The same, with neither | `python <module>.py` |
250+| `go.mod` and one `package main`, at the top or in one folder of `cmd/` | The program, built with `golang:1.25` into an image with nothing else |
251+| A `Gemfile` with Rails | `rails server`, after `assets:precompile`, on `ruby:3.3-slim` |
252+| A `Gemfile` and a `config.ru` | `rackup` |
253+| `composer.json`, and `artisan` with `public/index.php` (Laravel), or an `index.php` | Apache with PHP 8.3, after `composer install` |
254+
255+The Dockerfile g1t writes is printed whole in the build's log. To change
256+how the app is built or run, commit it as `Dockerfile` and edit it. It
257+leaves `.git`, `node_modules`, `.venv` and `__pycache__` out of the image.
258+
259+What a container gets, and what it must do:
260+
261+- **Listen on `0.0.0.0` at `PORT`.** `PORT` is the port its Dockerfile's
262+ first `EXPOSE` names (in its last stage), else `8080`. The first request
263+ waits up to 90 seconds for it to answer there; one that never does gets
264+ a 503 page saying so.
265+- **The project's [secrets and variables](#secrets-and-variables)** are
266+ its environment variables, set by g1t when it starts, never baked into
267+ the image. The image's build gets none of them: a step that needs one
268+ belongs in the app's start.
269+- **One instance, which sleeps.** It starts on the first request, and
270+ stops after five minutes without one; asleep, it costs nothing, and the
271+ next request starts it again in a few seconds. Its disk starts from the
272+ image each time: keep data in a database or a bucket.
273+- **Its size** is **Container size** under **Settings → Deployments**:
274+ `lite` (the default) to `standard-4`, as [What it costs](#what-it-costs)
275+ lists. The image may be as large as the size's disk, 2 GB for `lite`;
276+ a larger image fails the build, saying how large it was.
277+- **Built for `linux/amd64`**, with Docker, in the build's sandbox (or the
278+ workflow job's Docker, for [`g1t-runner deploy`](#deploying-from-a-workflow)),
279+ with network access as any build has. **Build command** is not used:
280+ the Dockerfile builds it.
281+
282+What Deployments cannot run yet is on
196283 [What g1t can't do yet](/about/limitations/#deployments).
197284
198285 ## Previews of branches
530617 | --- | --- | --- |
531618 | Production | On | Deploy the default branch on every push. |
532619 | Previews | On | A preview for every branch with an open pull request. |
533−| Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. |
620+| Build as | Auto | **Auto** (found from its files), **Static site**, **Workers** or **Docker**; see [Docker deployments](#docker-deployments). |
621+| Dockerfile | `Dockerfile` or `Containerfile` | For a container: its Dockerfile's path, inside the project. |
622+| Container size | `lite` | For a container: the instance it runs at, with its price an hour awake. |
623+| Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. Not used for a container. |
534624 | Output directory | Found by itself | What a static site serves. |
535625 | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. |
536626 | Build with a workflow | Off | Build through the deploy workflow g1t manages for the project; see [build with a workflow](#build-with-a-workflow). |
548638
549639 The build gets them as environment variables, with secrets hidden in its
550640 log. The running app gets them as bindings, `env.KEY`, put in place by g1t
551−rather than the build. Secrets reach a preview only when the pull
641+rather than the build; a container gets them as its environment variables
642+when it starts, and its image's build gets none. Secrets reach a preview only when the pull
552643 request's author has Write or higher on the repository, whether a member
553644 or an [outside collaborator](/guides/access-and-roles/#outside-collaborators),
554645 or it is g1t working on its own. For a pull request g1t made, whoever
578669 | Requests, to all of the workspace's apps | $0.30 a million | $0.36 a million |
579670 | CPU time your code spends computing (waiting on the network is not counted) | $0.02 a million ms | $0.024 a million ms |
580671 | Custom domains, with their certificates (a www/apex pair is two) | $0.10 a month | $0.12 a month, by the most the workspace had at once that month |
672+| A container, while it is awake, by the minute (see the sizes below) | From $0.0129 an hour (`lite`) | From $0.0155 an hour |
673+| Data a container sends | $0.05 a GB, at most | $0.06 a GB |
581674
582−Builds are charged when they finish. Requests, CPU time and custom domains
583−are counted through the month, so your limit and **Billing** follow them as
584−they happen, and charged once, on the first sweep after the month ends, as
585−one line: *Deployments in 2026-10: 1,240,000 requests, 3,100,000 CPU ms, 2
586−custom domains*.
675+A container's price is its size's memory, disk and vCPU while it is awake,
676+with the Durable Object in front of it, at Cloudflare's
677+[Containers prices](https://developers.cloudflare.com/containers/platform/pricing/)
678+plus 20%. Cloudflare bills CPU only as it is used; g1t charges every vCPU
679+as busy while the container is awake, so a container that mostly waits
680+costs g1t less than it is charged. Data sent is charged at the most a GB
681+costs anywhere ($0.05; $0.025 in North America and Europe), since g1t
682+cannot tell where it went. Each minute it is awake is charged from its
683+start, so a container woken by one request, and stopped five minutes after
684+it, is charged five minutes.
685+
686+| Size | vCPU | Memory | Disk, and the largest image | An hour awake | Awake all month (730 hours) |
687+| --- | --- | --- | --- | --- | --- |
688+| `lite` | 1/16 | 256 MiB | 2 GB | $0.0155 | $11.28 |
689+| `basic` | 1/4 | 1 GiB | 4 GB | $0.0404 | $29.46 |
690+| `standard-1` | 1/2 | 4 GiB | 8 GB | $0.0956 | $69.77 |
691+| `standard-2` | 1 | 6 GiB | 12 GB | $0.1616 | $117.95 |
692+| `standard-3` | 2 | 8 GiB | 16 GB | $0.2708 | $197.68 |
693+| `standard-4` | 4 | 12 GiB | 20 GB | $0.4880 | $356.24 |
694+
695+**Settings → Deployments** shows these with the price book's prices of the
696+day. A container that is visited all day never sleeps, and costs the last
697+column; one visited for an hour a day costs about a thirtieth of it.
587698
699+Builds are charged when they finish. Requests, CPU time, custom domains
700+and container time are counted through the month, so your limit and
701+**Billing** follow them as they happen, and charged once, on the first
702+sweep after the month ends, as one line: *Deployments in 2026-10:
703+1,240,000 requests, 3,100,000 CPU ms, 2 custom domains, 41.5 container
704+hours and 2.3 GB sent*.
705+
588706 A static site, or plain HTML, runs no code when it is visited: its files
589707 are served as they are, so serving it adds no requests or CPU time. Its
590708 builds are metered. A Workers project's code runs on every request, so its
591−requests and CPU time are metered too.
709+requests and CPU time are metered too. A container's Worker is metered the
710+same way, and its container by the minute while it is awake.
592711
593712 An app that no one visits costs nothing. Previews still come down when
594713 their pull request closes and after their idle days, to keep the list of
595714 what is up short.
596715
597716 The only things that stop a workspace's apps are its own spend limit
598−(apps pause with a notice, and come back once there is room) and the plan
599−ending (they come down).
717+(apps pause with a notice, and come back once there is room; a paused
718+container is stopped and starts no more) and the plan ending (they come
719+down).
600720
601721 Deployments used to be a $5 plan of their own. A Deployments subscription
602722 bought before keeps working until its current period ends, and is not
645765 upload them and nothing else. Files Cloudflare already has are skipped.
646766 4. The sandbox sends the Worker's code to g1t, which puts the app in
647767 g1t's [Workers for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/)
648− namespace, under the name in its address.
768+ namespace, under the name in its address. A container build goes as
769+ [below](#containers).
649770 5. A request to `*.g1t.page` reaches g1t's dispatcher, which runs the app
650771 by the name in the hostname. Nothing else is looked up. A request to a
651772 custom domain reaches the same dispatcher through Cloudflare for SaaS;
655776 `g1t.page`, not `g1t.sh`, so they share no cookies or origin with the site
656777 you sign in to.
657778
779+### Containers
780+
781+1. The build finds it is a container, and asks g1t where to push its
782+ image (the build's `registry` step). g1t mints a login to Cloudflare's
783+ registry, `registry.cloudflare.com`, that can **push only** and lasts
784+ **15 minutes**, and names the app's own repository,
785+ `<account>/g1t-<app>`, with the build's id as its tag. Asked before
786+ anything is built, so a build that could not push is not run.
787+2. The sandbox starts a Docker Engine of its own (as a workflow job's),
788+ builds the image for `linux/amd64`, checks its size against the
789+ container size's disk, pushes it with that login, and reports the
790+ image by its digest.
791+3. g1t runs only an image by digest in that app's repository, and asks
792+ the registry, with a pull login of its own, that the digest is there.
793+ It puts the app's Worker in the namespace: g1t's own code, never the
794+ project's, with a Durable Object class that holds the container, the
795+ project's secrets and variables, and a token for the app to report its
796+ running time with. Then it creates the app's Container application
797+ (at most one instance, at the project's size, attached to that class),
798+ or rolls it out to the new image.
799+4. A request reaches the app's Worker through the dispatcher, as any
800+ app's does. The Worker hands it to the app's Durable Object, which
801+ starts the container if it is asleep, with the environment, waits for
802+ it to answer on its port, and passes the request to it. Every minute it
803+ is awake, the object charges the next minute ahead and reports it to g1t
804+ (`POST /deployments/apps/<app>/meter` on the API, with its token); five
805+ minutes after the last request it stops the container.
806+5. A preview's container comes down with its pull request, its
807+ application and Worker deleted together.
808+
809+Why it is built this way, from Cloudflare's documentation and Wrangler's
810+own code (October 2026):
811+
812+- **Workers for Platforms runs containers.** A user Worker in a dispatch
813+ namespace may bind Containers, as a Durable Object
814+ ([WfP bindings](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/bindings/)).
815+ Wrangler's `deploy --dispatch-namespace` deploys containers by finding
816+ the Durable Object namespace of the class in that dispatch namespace and
817+ creating a Container application attached to it
818+ (`POST /accounts/{id}/containers/applications`, with
819+ `durable_objects.namespace_id`), then rolling out new images
820+ (`…/applications/{id}/rollouts`). So each app is a Worker of its own,
821+ with a class of its own, isolated as every app is, and the dispatcher
822+ routes to it unchanged. A single host Worker of g1t's could not run every
823+ app's image: a Durable Object starts only the images its Worker's
824+ configuration names.
825+- **The default scheduling policy.** It takes one image and size for the
826+ whole application, set through the API, which suits one app per
827+ application; the `durable_object` policy is in beta, and needs the
828+ image map uploaded with the Worker.
829+- **Registry logins.** Cloudflare mints registry credentials with
830+ `POST /accounts/{id}/containers/registries/registry.cloudflare.com/credentials`
831+ and `{ "permissions": ["push"], "expiration_minutes": 15 }`, as Wrangler
832+ does for its own pushes. They cannot be held to one repository, so g1t
833+ holds the build to its app's: it names the repository, runs nothing but a
834+ digest in it, and never gives a build a login that can pull, so no build
835+ can read another app's image. g1t's own Cloudflare token never reaches
836+ the sandbox.
837+- **Sizes and limits.** Containers' instance types are `lite` (1/16 vCPU,
838+ 256 MiB, 2 GB) through `standard-4` (4 vCPU, 12 GiB, 20 GB); an image
839+ may be as large as its instance's disk, and an account holds 50 GB of
840+ images ([limits](https://developers.cloudflare.com/containers/platform/limits/)).
841+- **Scale to zero.** Containers are billed every 10 ms while running, and
842+ stop being billed once asleep. The Durable Object stops its container
843+ itself after five minutes without a request, rather than leaving it to
844+ the inactivity timeout, since the object's own minute alarm, which
845+ meters it, would otherwise keep it awake.
846+
658847 ## Troubleshooting
659848
660849 | You see | Do |
663852 | A deployment **Skipped**, saying it reached its spend limit or that new compute is paused | The build was refused before it started. Follow the link in the message: raise the spend limit, prepay, or answer the spike, on Billing. |
664853 | "Stopped: unusual CPU use" | The build looked like it was mining. Contact support if it was a real build; see [abuse and mining](/guides/guardrails/#abuse-and-mining). |
665854 | "403" from a host in the build log | The build's network is restricted. Add the host to the project's allowed domains under **Settings → Guardrails**. |
666−| "found nothing to serve" | Add a `build` script, an `index.html`, or a Workers config; or set **Output directory**. |
855+| "found nothing to serve" | Add a `Dockerfile`, a `build` script, an `index.html`, or a Workers config; or set **Output directory**. |
856+| "Docker deployments are not switched on for g1t.page yet" | The project is a container, which g1t.page does not run yet. Set **Build as** to Static site or Workers if it can be built as one. See [Docker deployments](#docker-deployments). |
857+| "**Build as** is Docker, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. |
858+| "The image is 2.4 GB, more than the 2.0 GB an app of its size (lite) can hold" | Make the image smaller (a slim base image, a multi-stage build), or choose a larger **Container size**. |
859+| A container app says "The app did not start" | It did not answer on its port within 90 seconds. Make it listen on `0.0.0.0` at `PORT`, or `EXPOSE` the port it uses in its Dockerfile. Its output is in the build's log only while it builds; run the image locally with `docker run -e PORT=8080 -p 8080:8080` to see it start. |
667860 | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. |
668861 | "`d1_databases` is not provisioned on g1t.page yet" | The app deployed without that binding. See [Workers projects](#workers-projects). |
669862 | "`triggers.crons` (1 schedule) is not set up on g1t.page yet" | The app deployed, but its `scheduled` handler never runs. See [Workers projects](#workers-projects). |
690883 `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`.
691884 4. Deploy `services/deployments`, `services/pages`, and the runner.
692885
886+Docker deployments also need the token to have **Containers: Edit**, a
887+Workers Paid plan with Containers, and `CONTAINERS` set to `on` in
888+`services/deployments/wrangler.jsonc`; see
889+[Docker deployments](/guides/deploy-to-cloudflare/#docker-deployments)
890+for what to check first.
891+
693892 Custom domains also need Cloudflare for SaaS on the apps' zone. Turn it on
694893 under the zone's **SSL/TLS → Custom Hostnames**, give the token **SSL and
695894 Certificates: Edit** on the zone, and run `scripts/setup-custom-domains.sh`:
+5−2
157157 | App requests | Million | $0.30 | $0.36 |
158158 | App CPU time | Million CPU milliseconds | $0.02 | $0.024 |
159159 | Custom domains | Domain-month | $0.10 | $0.12 |
160+| [Container apps](/guides/deployments/#docker-deployments), while awake | Second, by the minute: vCPU, memory, disk and the Durable Object, by the size | From $0.0129 an hour (`lite`) | Cost + 20%: from $0.0155 an hour |
161+| Data container apps send | GB | $0.05 | $0.06 |
160162 | Private storage, past the free 1 GB | GB-month | $0.50 | $0.60 |
161163 | [Actions cache](#actions-cache) storage | GB-month | $0.015 | $0.018 |
162164 | Git operations, past the free 50,000 a month | 1,000 | $0.15 | $0.18 |
299301 | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) |
300302 | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time), more on a [larger machine](#workflow-jobs-on-larger-machines) |
301303 | [Actions cache](#actions-cache) | What it holds, on the plan only |
302−| [Deployments](/guides/deployments/) | Builds, requests, CPU time and custom domains, from the first. Projects and previews are not charged. |
304+| [Deployments](/guides/deployments/) | Builds, requests, CPU time, custom domains and container time, from the first. Projects and previews are not charged. |
303305 | [Private repository storage](#storage-search-embeddings-and-scans) | Past the free 1 GB, on the plan only |
304306 | [Git operations](#git-operations) | Past the free 50,000 a month, on the plan only |
305307 | [Search embeddings](#storage-search-embeddings-and-scans) | For private text, on the plan only |
374376 | Deploy builds | Per second |
375377 | App requests and CPU | Per million requests, per million CPU milliseconds |
376378 | Custom domains | Per domain-month |
379+| Container apps | Per vCPU-second, GiB-second of memory and GB-second of disk while awake, a second of the Durable Object in front of each, and per GB sent |
377380 | Private storage past the free 1 GB | Per GB-month |
378381 | Actions cache storage | Per GB-month |
379382 | Git operations past the free 50,000 a month | Per 1,000 |
10831086 | Runs on your own model provider | Older months only: the flat fee runs on your own provider used to carry. |
10841087 | Sandbox time | Each sandbox's time. |
10851088 | Self-hosted runner time | Each job on your own runners, at $0. |
1086− | Deployments | Builds as they finish; each month's requests, CPU time and custom domains when it closes. |
1089+ | Deployments | Builds as they finish; each month's requests, CPU time, custom domains and container time when it closes. |
10871090 | Private storage | Storage past the free 1 GB, once a month. |
10881091 | Actions cache storage | What `actions/cache` and artifacts held, on the plan, once a month. |
10891092 | Git operations | Operations past the free 50,000, once a month. |
+1−0
107107 | [Security](/guides/security/) | Secret push protection, history scanning and pull requests that upgrade vulnerable dependencies. | <Status is="live" /> |
108108 | [Deployments](/guides/deployments/) | A live preview of every pull request on g1t.page, production on merge, and custom domains. | <Status is="live" /> |
109109 | Preview data | Each preview gets its own copy of the database and bucket, deleted when the pull request closes. | <Status is="coming" /> |
110+| [Docker deployments](/guides/deployments/#docker-deployments) | Any app with a Dockerfile, or a Node.js, Python, Go, Ruby or PHP server, runs as a container that sleeps when no one visits. | <Status is="coming" /> |
110111 | Rollback and always on | One click back to any deploy, request logs, and container apps kept warm. | <Status is="coming" /> |
111112 | [GitHub](/guides/github/) and [mirroring](/guides/mirroring/) | Import repositories, keep them mirrored, or push changes back. | <Status is="live" /> |
112113 | Git over SSH | `git@g1t.sh` remotes. Use HTTPS until then. | <Status is="coming" /> |
+0−0

Binary or large file; its contents are not shown.

+19−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { detectedLabel, sizeLabel } from "./deploy-kind.ts";
5+
6+const NAMES = { astro: "Astro", dockerfile: "Dockerfile", flask: "Flask", nextjs: "Next.js" };
7+
8+test("what a build found is said with what it was built with", () => {
9+ assert.equal(detectedLabel("static", "astro", NAMES), "Static site · Astro");
10+ assert.equal(detectedLabel("container", "dockerfile", NAMES), "Container · Dockerfile");
11+ assert.equal(detectedLabel("container", "flask", NAMES), "Container · Flask, with a Dockerfile g1t wrote");
12+ assert.equal(detectedLabel("workers", null, NAMES), "Workers project");
13+ assert.equal(detectedLabel("html", "unknown", NAMES), "Plain HTML");
14+});
15+
16+test("a container size reads plainly", () => {
17+ assert.equal(sizeLabel({ vcpu: 1 / 16, memoryGib: 0.25, diskGb: 2 }), "1/16 vCPU, 256 MiB, 2 GB disk");
18+ assert.equal(sizeLabel({ vcpu: 2, memoryGib: 8, diskGb: 16 }), "2 vCPU, 8 GiB, 16 GB disk");
19+});
+47−0
1+/**
2+ * What Settings, Deployments says a project is, from its last build: its
3+ * kind and what it was built with (`Static site · Astro`, `Container ·
4+ * Dockerfile`), and what running it costs, in a line. No imports beyond
5+ * types, so it can be tested under Node.
6+ */
7+
8+import type { BuildAs, DetectedKind } from "@g1t/contracts";
9+
10+export const KIND_LABELS: Record<DetectedKind, string> = {
11+ static: "Static site",
12+ html: "Plain HTML",
13+ workers: "Workers project",
14+ container: "Container",
15+};
16+
17+export const KIND_COSTS: Record<DetectedKind, string> = {
18+ static: "Serving its files runs no code, so it adds no requests or CPU time. Builds are metered.",
19+ html: "Its files are served as they are and run no code, so serving adds no requests or CPU time. Builds are metered.",
20+ workers: "Its code runs on every request: requests and CPU time are metered, and so are builds.",
21+ container:
22+ "Its container runs only while it is awake: from the first request until it has had none for a few minutes, metered by the minute at its size, with what it sends. Builds are metered.",
23+};
24+
25+/** `Static site · Astro`, `Container · Dockerfile`, or the kind alone when nothing names what it was built with. */
26+export function detectedLabel(kind: DetectedKind, framework: string | null | undefined, names: Record<string, string>): string {
27+ const built = framework ? (names[framework] ?? null) : null;
28+ if (!built) return KIND_LABELS[kind];
29+ // A server g1t wrote a Dockerfile for says so.
30+ const written = kind === "container" && framework !== "dockerfile" ? `${built}, with a Dockerfile g1t wrote` : built;
31+ return `${KIND_LABELS[kind]} · ${written}`;
32+}
33+
34+/** The Build as choices, as the select shows them. */
35+export const BUILD_AS_OPTIONS: { value: BuildAs; label: string }[] = [
36+ { value: "auto", label: "Auto: found from its files" },
37+ { value: "static", label: "Static site" },
38+ { value: "workers", label: "Workers" },
39+ { value: "docker", label: "Docker" },
40+];
41+
42+/** `1/16 vCPU, 256 MiB, 2 GB disk`. */
43+export function sizeLabel(size: { vcpu: number; memoryGib: number; diskGb: number }): string {
44+ const vcpu = size.vcpu >= 1 ? String(size.vcpu) : `1/${Math.round(1 / size.vcpu)}`;
45+ const memory = size.memoryGib >= 1 ? `${size.memoryGib} GiB` : `${Math.round(size.memoryGib * 1024)} MiB`;
46+ return `${vcpu} vCPU, ${memory}, ${size.diskGb} GB disk`;
47+}
+51−25
11 import { Form, Link } from "react-router";
22
3−import type { DetectedKind } from "@g1t/contracts";
3+import { DEFAULT_APP_INSTANCE, FRAMEWORK_NAMES, type ContainerOffer, type DetectedKind } from "@g1t/contracts";
44
55 import type { Route } from "./+types/settings-deployments";
66 import { page } from "../../lib/meta";
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
88 import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
99 import { Card } from "../../components/ui/card";
10+import { SelectField } from "../../components/ui/select";
1011 import { SwitchCard } from "../../components/ui/switch";
12+import { BUILD_AS_OPTIONS, KIND_COSTS, detectedLabel, sizeLabel } from "../../lib/deploy-kind";
13+import { money } from "../../lib/money";
1114 import { neverDeploys } from "../../lib/project-kind";
1215 import { deployments, projects } from "../../lib/services.server";
1316 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
5154 outputDir: String(form.get("outputDir") ?? ""),
5255 idleDays: Number(form.get("idleDays")),
5356 viaWorkflow: on("viaWorkflow"),
57+ buildAs: String(form.get("buildAs") ?? "auto") as "auto",
58+ dockerfile: String(form.get("dockerfile") ?? ""),
59+ instanceType: String(form.get("instanceType") ?? DEFAULT_APP_INSTANCE),
5460 });
5561 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
5662 }
6369 );
6470 }
6571
66−/** What each kind of project is called, and what running it costs, in a line. */
67−const DETECTED: Record<DetectedKind, { label: string; cost: string }> = {
68− static: {
69− label: "Static site",
70− cost: "Serving its files runs no code, so it adds no requests or CPU time. Builds are metered.",
71− },
72− html: {
73− label: "Plain HTML",
74− cost: "Its files are served as they are and run no code, so serving adds no requests or CPU time. Builds are metered.",
75− },
76− workers: {
77− label: "Workers project",
78− cost: "Its code runs on every request: requests and CPU time are metered, and so are builds.",
79− },
80−};
81−
82−/** What g1t found the project to be at its last build, and what that costs. */
83−function Detected({ kind }: { kind: DetectedKind | null }) {
84− const found = kind ? DETECTED[kind] : null;
72+/** What g1t found the project to be at its last build, with what it was built with, and what that costs. */
73+function Detected({ kind, framework }: { kind: DetectedKind | null; framework: string | null }) {
8574 return (
8675 <Card asChild className="mb-6 p-5">
8776 <section>
8877 <h2 className="text-sm font-medium">What g1t detected</h2>
8978 <p className="mt-1 text-sm">
90− {found ? found.label : <span className="text-muted">Detected at the first build</span>}
79+ {kind ? detectedLabel(kind, framework, FRAMEWORK_NAMES) : <span className="text-muted">Detected at the first build</span>}
9180 </p>
9281 <p className="mt-1 text-xs text-muted">
93− {found
94− ? found.cost
95− : "A static site's files cost nothing to serve; a Workers project's requests and CPU time are metered. Builds are metered either way."}
82+ {kind
83+ ? KIND_COSTS[kind]
84+ : "A static site's files cost nothing to serve; a Workers project's requests and CPU time are metered; a container is metered while it is awake. Builds are metered either way."}
9685 </p>
9786 </section>
9887 </Card>
9988 );
10089 }
10190
91+/** Each container size with what an hour awake costs, for choosing one. */
92+function ContainerSizes({ offer, chosen }: { offer: ContainerOffer; chosen: string }) {
93+ return (
94+ <div className="space-y-3">
95+ <Field
96+ label="Container size"
97+ hint={`For a container: what it runs at, and how large its image can be. It sleeps after ${offer.sleepMinutes} minutes without a request and costs nothing asleep.`}
98+ >
99+ <SelectField
100+ name="instanceType"
101+ defaultValue={chosen}
102+ aria-label="Container size"
103+ options={offer.prices.map((size) => ({
104+ value: size.type,
105+ label: `${size.type}: ${sizeLabel(size)}`,
106+ description: `${money(size.hourlyMicros, { precise: true })} an hour awake, at most`,
107+ }))}
108+ />
109+ </Field>
110+ <p className="text-xs text-muted">
111+ Charged by the minute while awake, at what Cloudflare charges g1t plus 20%, with every vCPU counted as busy; data a
112+ container sends is {money(offer.egressGbMicros, { precise: true })} a GB.
113+ {!offer.available && " Docker deployments are not switched on for g1t.page yet: until they are, a container build fails, or builds the site the project is without its Dockerfile."}
114+ </p>
115+ </div>
116+ );
117+}
118+
102119 export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
103120 const { settings, notDeploying } = loaderData;
104121 const base = `/${params.owner}/${params.repo}`;
110127 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
111128 </div>
112129
113− <Detected kind={settings.detected ?? null} />
130+ <Detected kind={settings.detected ?? null} framework={settings.framework ?? null} />
114131
115132 {!settings.enabled ? (
116133 <Card asChild className="p-5">
154171 <Check name="viaWorkflow" on={settings.viaWorkflow ?? false} title="Build with a workflow">
155172 Build through a workflow g1t manages for this project, listed under Actions with its runs and logs. It runs on g1t's machines like any workflow, and a workflow named g1t in .g1t/workflows takes its place.
156173 </Check>
174+ <div className="grid gap-4 md:grid-cols-2">
175+ <Field label="Build as" hint="Auto finds it from the project's files: a Wrangler config is Workers, a framework that builds a static site stays one, and a Dockerfile or a server makes it a container.">
176+ <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} />
177+ </Field>
178+ <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty.">
179+ <Input name="dockerfile" defaultValue={settings.dockerfile ?? ""} placeholder="Dockerfile" />
180+ </Field>
181+ </div>
182+ {settings.containers && <ContainerSizes offer={settings.containers} chosen={settings.instanceType ?? DEFAULT_APP_INSTANCE} />}
157183 <div className="grid gap-4 md:grid-cols-3">
158184 <Field label="Build command" hint="Instead of the project's own build script.">
159185 <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
+10−0
10691069 /// What one custom hostname costs g1t a month (Cloudflare for SaaS):
10701070 /// $0.10.
10711071 pub const MICROS_PER_DOMAIN_MONTH: i64 = 100_000;
1072+ /// Container apps (Docker deployments), at Cloudflare Containers' list
1073+ /// prices: a vCPU-second, a GiB-second of memory and a GB-second of
1074+ /// disk while awake, the Durable Object in front of each instance a
1075+ /// second while it runs, and a GB sent at the most Cloudflare charges
1076+ /// for one anywhere. The price book's `app_container_*` meters.
1077+ pub const MICROS_PER_CONTAINER_VCPU_SECOND: f64 = 20.0;
1078+ pub const MICROS_PER_CONTAINER_GIB_SECOND: f64 = 2.5;
1079+ pub const MICROS_PER_CONTAINER_DISK_GB_SECOND: f64 = 0.07;
1080+ pub const MICROS_PER_CONTAINER_SECOND: f64 = 1.5625;
1081+ pub const MICROS_PER_CONTAINER_EGRESS_GB: i64 = 50_000;
10721082 }
10731083
10741084 /// `record_sandbox`: how long one sandbox ran for a workspace, reported by
+3−0
10221022 // A sandbox's own Docker Engine, started for the build; in a workflow
10231023 // step, the job's.
10241024 if clones() {
1025+ if std::env::var("G1T_DOCKER").as_deref() != Ok("on") {
1026+ bail!("Docker is turned off for g1t's sandboxes here, so a container cannot be built.");
1027+ }
10251028 crate::docker::engine::enable(crate::docker::engine::Options::default())
10261029 .map_err(|problem| anyhow::anyhow!("Docker is not available in this sandbox: {problem}"))?;
10271030 let started = crate::docker::engine::ensure_started();
+16−0
883883 microsPerBuildSecond: 15,
884884 /** One custom domain for a month. */
885885 microsPerDomainMonth: 100_000,
886+ /**
887+ * Container apps, at Cloudflare Containers' list prices: a vCPU-second
888+ * $0.00002, a GiB-second of memory $0.0000025, a GB-second of disk
889+ * $0.00000007, the Durable Object in front of each instance while it
890+ * runs (128 MB at $12.50 per million GB-seconds), and a GB of egress
891+ * at $0.05, the most Cloudflare charges for one anywhere (Oceania, Korea
892+ * and Taiwan; $0.025 in North America and Europe), since g1t cannot
893+ * tell where a container's traffic went.
894+ */
895+ container: {
896+ vcpuSecond: 20,
897+ memoryGibSecond: 2.5,
898+ diskGbSecond: 0.07,
899+ instanceSecond: 1.5625,
900+ egressGb: 50_000,
901+ },
886902 } as const;
887903
888904 export type FeaturePlan = {
+142−3
4747 primaryDomain: string | null;
4848 /**
4949 * What the last finished build found the project to be: a Workers
50− * project (it has a Workers config), a static site its build wrote, or
51− * plain HTML served as it is. Null until a build has finished. Read-only.
50+ * project (it has a Workers config), a static site its build wrote,
51+ * plain HTML served as it is, or a container. Null until a build has
52+ * finished. Read-only.
5253 */
5354 detected: DetectedKind | null;
55+ /**
56+ * What the last finished build found it built with: a framework's id
57+ * (`astro`…), or for a container `dockerfile` or the server g1t wrote a
58+ * Dockerfile for (`flask`…). See `FRAMEWORK_NAMES`. Read-only.
59+ */
60+ framework?: string | null;
61+ /** What it is built as: found from its files (`auto`), or as the project says. */
62+ buildAs?: BuildAs;
63+ /** Its Dockerfile, relative to the project, when not `Dockerfile` or `Containerfile`. */
64+ dockerfile?: string | null;
65+ /** The size its container runs at (`APP_INSTANCES`), when it is one. */
66+ instanceType?: string;
67+ /** Whether g1t.page runs containers here yet, and what each size costs. Read-only. */
68+ containers?: ContainerOffer;
5469 };
5570
56−export type DetectedKind = "workers" | "static" | "html";
71+export type DetectedKind = "workers" | "static" | "html" | "container";
72+
73+/**
74+ * What a project is built as. `auto` finds it from its files: a Wrangler
75+ * config is Workers, a framework that builds a static site or a Worker is
76+ * that, a framework that needs a server or a project with a Dockerfile
77+ * (or a server g1t can write one for) is a container.
78+ */
79+export type BuildAs = "auto" | "static" | "workers" | "docker";
80+
81+export const BUILD_AS: readonly BuildAs[] = ["auto", "static", "workers", "docker"];
5782
83+/**
84+ * A size a container app runs at: one of Cloudflare Containers' instance
85+ * types. Memory and disk are paid for while it is awake, CPU as it is
86+ * used, and its image can be as large as its disk.
87+ */
88+export type AppInstance = { type: string; vcpu: number; memoryGib: number; diskGb: number };
89+
90+/** Cloudflare Containers' instance types, smallest first. */
91+export const APP_INSTANCES: readonly AppInstance[] = [
92+ { type: "lite", vcpu: 1 / 16, memoryGib: 0.25, diskGb: 2 },
93+ { type: "basic", vcpu: 1 / 4, memoryGib: 1, diskGb: 4 },
94+ { type: "standard-1", vcpu: 1 / 2, memoryGib: 4, diskGb: 8 },
95+ { type: "standard-2", vcpu: 1, memoryGib: 6, diskGb: 12 },
96+ { type: "standard-3", vcpu: 2, memoryGib: 8, diskGb: 16 },
97+ { type: "standard-4", vcpu: 4, memoryGib: 12, diskGb: 20 },
98+];
99+
100+/** What a container app runs at unless its project says: the smallest. */
101+export const DEFAULT_APP_INSTANCE = "lite";
102+
103+/** A container app sleeps after this long with no request, and costs nothing while asleep. */
104+export const CONTAINER_SLEEP_MINUTES = 5;
105+
106+/** An instance type by name; the default one for anything else. */
107+export function appInstance(type: string | null | undefined): AppInstance {
108+ return APP_INSTANCES.find((instance) => instance.type === type) ?? APP_INSTANCES[0];
109+}
110+
111+/** What container apps cost g1t, in millionths of a dollar, from the price book. */
112+export type ContainerCosts = {
113+ vcpuSecond: number;
114+ memoryGibSecond: number;
115+ diskGbSecond: number;
116+ /** The Durable Object in front of each instance, billed while the instance runs. */
117+ instanceSecond: number;
118+ egressGb: number;
119+};
120+
121+/**
122+ * What one second awake of `instance` costs g1t at most: its memory, disk
123+ * and Durable Object, and every vCPU busy. Cloudflare bills CPU as it is
124+ * used, so an app that is mostly waiting costs g1t less; g1t charges the
125+ * whole vCPU while it is awake, so it never sells below cost.
126+ */
127+export function containerSecondMicros(instance: AppInstance, costs: ContainerCosts): number {
128+ return (
129+ instance.vcpu * costs.vcpuSecond +
130+ instance.memoryGib * costs.memoryGibSecond +
131+ instance.diskGb * costs.diskGbSecond +
132+ costs.instanceSecond
133+ );
134+}
135+
136+/** One size as Settings, Deployments shows it: what an hour awake is charged, the margin included. */
137+export type ContainerPrice = AppInstance & { hourlyMicros: number };
138+
139+export type ContainerOffer = {
140+ /** False until Docker deployments are switched on for g1t.page. */
141+ available: boolean;
142+ prices: ContainerPrice[];
143+ /** What one GB a container sends is charged, the margin included. */
144+ egressGbMicros: number;
145+ sleepMinutes: number;
146+};
147+
148+/**
149+ * What a build found a project built with, by the id `g1t-runner detect`
150+ * prints as `framework`, for people.
151+ */
152+export const FRAMEWORK_NAMES: Record<string, string> = {
153+ vite: "Vite",
154+ "vite-react": "React (Vite)",
155+ "vite-vue": "Vue (Vite)",
156+ "vite-svelte": "Svelte (Vite)",
157+ "vite-solid": "Solid (Vite)",
158+ "vite-preact": "Preact (Vite)",
159+ astro: "Astro",
160+ nextjs: "Next.js",
161+ sveltekit: "SvelteKit",
162+ nuxt: "Nuxt",
163+ "react-router": "React Router",
164+ remix: "Remix",
165+ gatsby: "Gatsby",
166+ docusaurus: "Docusaurus",
167+ eleventy: "Eleventy",
168+ angular: "Angular",
169+ "create-react-app": "Create React App",
170+ hugo: "Hugo",
171+ jekyll: "Jekyll",
172+ mdbook: "mdBook",
173+ dockerfile: "Dockerfile",
174+ node: "Node.js",
175+ django: "Django",
176+ flask: "Flask",
177+ fastapi: "FastAPI",
178+ python: "Python",
179+ go: "Go",
180+ rails: "Rails",
181+ rack: "Rack",
182+ laravel: "Laravel",
183+ php: "PHP",
184+};
185+
186+/** A framework id a build reported, if it is one g1t knows. */
187+export function frameworkId(value: unknown): string | null {
188+ return typeof value === "string" && Object.hasOwn(FRAMEWORK_NAMES, value) ? value : null;
189+}
190+
58191 export type DeployKind = "preview" | "production";
59192
60193 export type DeployStatus =
88221 warnings: string[];
89222 /** How long the build ran, in seconds; charged at the container price. */
90223 buildSeconds: number | null;
224+ /** What the build found it to be and built with, once it has finished (see `DeploySettings`). */
225+ detected?: DetectedKind | null;
226+ framework?: string | null;
91227 createdBy: string;
92228 /** RFC 3339. */
93229 createdAt: string;
127263 buildSeconds: number;
128264 /** Charged so far this month for builds, in millionths of a dollar. */
129265 buildMicros: number;
266+ /** Seconds container apps have been awake this month, and what that has cost g1t so far (charged when the month closes). */
267+ containerSeconds?: number;
268+ containerMicros?: number;
130269 /** RFC 3339: when requests and CPU time last went up. */
131270 countedAt: string | null;
132271 };
+29−0
1+-- Container apps on g1t.page (Docker deployments): what their parts cost
2+-- g1t, at Cloudflare Containers' list prices, sold at cost plus 20% like
3+-- everything else. The deployments service meters each app's container by
4+-- the started minute while it is awake and charges a month's time once
5+-- the month is over, under Deployments. CPU is charged for the instance's
6+-- whole vCPU while awake, the most Cloudflare can bill for it (it bills
7+-- CPU as used), so g1t never sells below cost.
8+INSERT INTO prices (meter, title, unit, cost_micros, markup_percent, source, updated_at) VALUES
9+ ('app_container_vcpu_second', 'Container apps: vCPU while awake', 'vCPU-second', 20, 20, 'list', '2026-10-12T00:00:00Z'),
10+ ('app_container_memory_gib_second', 'Container apps: memory while awake', 'GiB-second', 2.5, 20, 'list', '2026-10-12T00:00:00Z'),
11+ ('app_container_disk_gb_second', 'Container apps: disk while awake', 'GB-second', 0.07, 20, 'list', '2026-10-12T00:00:00Z'),
12+ ('app_container_second', 'Container apps: the Durable Object in front of each', 'second', 1.5625, 20, 'list', '2026-10-12T00:00:00Z'),
13+ ('app_container_egress_gb', 'Container apps: data sent', 'GB', 50000, 20, 'list', '2026-10-12T00:00:00Z')
14+ON CONFLICT (meter) DO NOTHING;
15+
16+INSERT INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, reason, created_at) VALUES
17+ ('prc_new_app_container_vcpu', 'app_container_vcpu_second', 20, 20, 20, 'Now metered: container apps on g1t.page, at Cloudflare Containers'' price', '2026-10-12T00:00:00Z'),
18+ ('prc_new_app_container_memory', 'app_container_memory_gib_second', 2.5, 2.5, 20, 'Now metered: container apps on g1t.page, at Cloudflare Containers'' price', '2026-10-12T00:00:00Z'),
19+ ('prc_new_app_container_disk', 'app_container_disk_gb_second', 0.07, 0.07, 20, 'Now metered: container apps on g1t.page, at Cloudflare Containers'' price', '2026-10-12T00:00:00Z'),
20+ ('prc_new_app_container_do', 'app_container_second', 1.5625, 1.5625, 20, 'Now metered: the Durable Object in front of each container app, while it runs', '2026-10-12T00:00:00Z'),
21+ ('prc_new_app_container_egress', 'app_container_egress_gb', 50000, 50000, 20, 'Now metered: data container apps send, at the most Cloudflare charges for a GB anywhere', '2026-10-12T00:00:00Z')
22+ON CONFLICT (id) DO NOTHING;
23+
24+INSERT OR IGNORE INTO price_versions (id, meter, version, cost_micros, markup_percent, effective_at, reason, created_by, created_at, applied_at) VALUES
25+ ('pv_app_container_vcpu_second_1', 'app_container_vcpu_second', 1, 20, 20, '2026-10-12T00:00:00Z', 'Container apps', 'migration', '2026-10-12T00:00:00Z', '2026-10-12T00:00:00Z'),
26+ ('pv_app_container_memory_gib_second_1', 'app_container_memory_gib_second', 1, 2.5, 20, '2026-10-12T00:00:00Z', 'Container apps', 'migration', '2026-10-12T00:00:00Z', '2026-10-12T00:00:00Z'),
27+ ('pv_app_container_disk_gb_second_1', 'app_container_disk_gb_second', 1, 0.07, 20, '2026-10-12T00:00:00Z', 'Container apps', 'migration', '2026-10-12T00:00:00Z', '2026-10-12T00:00:00Z'),
28+ ('pv_app_container_second_1', 'app_container_second', 1, 1.5625, 20, '2026-10-12T00:00:00Z', 'Container apps', 'migration', '2026-10-12T00:00:00Z', '2026-10-12T00:00:00Z'),
29+ ('pv_app_container_egress_gb_1', 'app_container_egress_gb', 1, 50000, 20, '2026-10-12T00:00:00Z', 'Container apps', 'migration', '2026-10-12T00:00:00Z', '2026-10-12T00:00:00Z');
+1−0
16101610 include_str!("../migrations/0052_cloudflare_cycle.sql"),
16111611 include_str!("../migrations/0053_models_at_cost.sql"),
16121612 include_str!("../migrations/0054_agent_attribution.sql"),
1613+ include_str!("../migrations/0055_app_containers.sql"),
16131614 ];
16141615
16151616 /// The columns of `table` after the migrations: each with whether an
+39−0
1+-- Docker deployments (src/containers.ts).
2+
3+-- Each project's choices: what it is built as (auto, static, workers or
4+-- docker), its Dockerfile's path when not one g1t finds by itself, and the
5+-- size its container runs at (a Cloudflare Containers instance type).
6+ALTER TABLE settings ADD COLUMN build_as TEXT NOT NULL DEFAULT 'auto';
7+ALTER TABLE settings ADD COLUMN dockerfile TEXT;
8+ALTER TABLE settings ADD COLUMN instance_type TEXT NOT NULL DEFAULT 'lite';
9+
10+-- What each build found the project built with, next to `detected`: a
11+-- framework's id (`astro`), or for a container `dockerfile` or the server
12+-- g1t wrote a Dockerfile for (`flask`). And a container's image, by digest.
13+ALTER TABLE deployments ADD COLUMN framework TEXT;
14+ALTER TABLE deployments ADD COLUMN image TEXT;
15+
16+-- An app that is a container: its Container application, the size it runs
17+-- at, and the hash of the token its Worker reports its running time with.
18+-- `runtime` is `worker` for every other app.
19+ALTER TABLE apps ADD COLUMN runtime TEXT NOT NULL DEFAULT 'worker';
20+ALTER TABLE apps ADD COLUMN container_app_id TEXT;
21+ALTER TABLE apps ADD COLUMN instance_type TEXT;
22+ALTER TABLE apps ADD COLUMN meter_hash TEXT;
23+
24+-- How long each app's container ran in a month, and what it sent, as its
25+-- Worker reports them: one row per counter (`epoch`, the Durable Object's
26+-- own, which starts again at zero if it is ever new), each only ever
27+-- raised, so a report repeated or out of order changes nothing.
28+CREATE TABLE container_meters (
29+ script TEXT NOT NULL,
30+ epoch TEXT NOT NULL,
31+ month TEXT NOT NULL,
32+ workspace TEXT NOT NULL,
33+ instance_type TEXT NOT NULL,
34+ awake_seconds INTEGER NOT NULL DEFAULT 0,
35+ egress_bytes INTEGER NOT NULL DEFAULT 0,
36+ reported_at TEXT NOT NULL,
37+ PRIMARY KEY (script, epoch, month)
38+);
39+CREATE INDEX container_meters_by_month ON container_meters (month, workspace);
+112−0
99 * else.
1010 */
1111
12+import { APP_CLASS, APP_WORKER, REGISTRY_HOST, applicationName, applicationRequest, pushLoginRequest, rolloutRequest } from "./containers";
1213 import { redirectScript } from "./redirect";
1314
1415 const API = "https://api.cloudflare.com/client/v4";
156157 await this.call("PUT", this.scriptPath(script), form);
157158 }
158159
160+ // ---- Container apps (containers.ts) ---------------------------------
161+
162+ /**
163+ * A login to Cloudflare's registry for this account: push only (never
164+ * pull, so a build cannot read another app's image), for minutes.
165+ */
166+ async registryLogin(permissions: ("push" | "pull")[] = ["push"]): Promise<{ username: string; password: string }> {
167+ const body = permissions.includes("pull") ? { permissions, expiration_minutes: 5 } : pushLoginRequest();
168+ const result = await this.call<{ username: string; password?: string }>(
169+ "POST",
170+ `/accounts/${this.account}/containers/registries/${REGISTRY_HOST}/credentials`,
171+ JSON.stringify(body),
172+ "application/json",
173+ );
174+ if (!result.password) throw new Error("Cloudflare's registry gave no password.");
175+ return { username: result.username, password: result.password };
176+ }
177+
178+ /**
179+ * Whether `repository` holds the image `digest`: asked of the registry
180+ * itself, with a pull login of g1t's own, through the token exchange
181+ * registries use (`WWW-Authenticate: Bearer`).
182+ */
183+ async hasImage(repository: string, digest: string): Promise<boolean> {
184+ const login = await this.registryLogin(["pull"]);
185+ const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`;
186+ const url = `https://${REGISTRY_HOST}/v2/${repository}/manifests/${digest}`;
187+ const accept = "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json";
188+ let response = await fetch(url, { method: "HEAD", headers: { authorization: basic, accept } });
189+ const challenge = response.headers.get("www-authenticate") ?? "";
190+ if (response.status === 401 && /^bearer /i.test(challenge)) {
191+ const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
192+ const token = new URL(params.realm);
193+ if (params.service) token.searchParams.set("service", params.service);
194+ token.searchParams.set("scope", params.scope ?? `repository:${repository}:pull`);
195+ const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as {
196+ token?: string;
197+ access_token?: string;
198+ } | null;
199+ const bearer = answer?.token ?? answer?.access_token;
200+ if (!bearer) return false;
201+ response = await fetch(url, { method: "HEAD", headers: { authorization: `Bearer ${bearer}`, accept } });
202+ }
203+ return response.ok;
204+ }
205+
206+ /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */
207+ async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> {
208+ const form = new FormData();
209+ form.append("metadata", JSON.stringify(metadata));
210+ form.append("index.js", new File([APP_WORKER], "index.js", { type: "application/javascript+module" }));
211+ await this.call("PUT", this.scriptPath(script), form);
212+ }
213+
214+ /** The Durable Object namespace of a container app's Worker: what its Container application is attached to. */
215+ async appNamespace(script: string): Promise<string> {
216+ for (let page = 1; page <= 20; page++) {
217+ const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>(
218+ "GET",
219+ `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`,
220+ );
221+ const found = namespaces.find((n) => n.class === APP_CLASS && n.script === script && n.dispatch_namespace === this.namespace);
222+ if (found) return found.id;
223+ if (namespaces.length < 1000) break;
224+ }
225+ throw new Error(`The app's Durable Object namespace was not found for ${script}.`);
226+ }
227+
228+ /** The Container application named `name`, if there is one. */
229+ async containerApplication(name: string): Promise<{ id: string } | null> {
230+ const apps = await this.call<{ id: string; name: string }[]>("GET", `/accounts/${this.account}/containers/applications`);
231+ return apps.find((app) => app.name === name) ?? null;
232+ }
233+
234+ /**
235+ * Runs `image` at `instanceType` for the app `script`: its Container
236+ * application is made the first time, and rolled out to after.
237+ */
238+ async runContainer(script: string, image: string, instanceType: string, description: string): Promise<string> {
239+ const name = applicationName(script);
240+ const existing = await this.containerApplication(name);
241+ if (existing) {
242+ await this.call(
243+ "POST",
244+ `/accounts/${this.account}/containers/applications/${existing.id}/rollouts`,
245+ JSON.stringify(rolloutRequest(image, instanceType, description)),
246+ "application/json",
247+ );
248+ return existing.id;
249+ }
250+ const namespaceId = await this.appNamespace(script);
251+ const created = await this.call<{ id: string }>(
252+ "POST",
253+ `/accounts/${this.account}/containers/applications`,
254+ JSON.stringify(applicationRequest(name, image, instanceType, namespaceId)),
255+ "application/json",
256+ );
257+ return created.id;
258+ }
259+
260+ /** Deletes the app's Container application, and its instances with it. Already gone is fine. */
261+ async deleteContainer(script: string, id?: string | null): Promise<void> {
262+ const found = id ? { id } : await this.containerApplication(applicationName(script)).catch(() => null);
263+ if (!found) return;
264+ try {
265+ await this.call("DELETE", `/accounts/${this.account}/containers/applications/${found.id}`);
266+ } catch (error) {
267+ if (!/404|not found/i.test(String(error))) throw error;
268+ }
269+ }
270+
159271 /** Every app in the namespace, with when it was last changed. */
160272 async listScripts(): Promise<{ id: string; modified_on: string }[]> {
161273 return this.call<{ id: string; modified_on: string }[]>(
+278−0
1+/**
2+ * A container build through the whole service, on Node: a D1 made of
3+ * node:sqlite with the service's migrations, the other services as fakes,
4+ * and Cloudflare's API (and its registry) as a recorded fetch. Run with
5+ * `--experimental-transform-types` (see package.json).
6+ */
7+
8+import assert from "node:assert/strict";
9+import { createHash } from "node:crypto";
10+import { readdirSync, readFileSync } from "node:fs";
11+import { registerHooks } from "node:module";
12+import { DatabaseSync } from "node:sqlite";
13+import { beforeEach, test } from "node:test";
14+
15+registerHooks({
16+ resolve(specifier, context, next) {
17+ try {
18+ return next(specifier, context);
19+ } catch (error) {
20+ if (specifier.startsWith(".")) return next(`${specifier}.ts`, context);
21+ throw error;
22+ }
23+ },
24+});
25+const { default: worker } = await import("./index.ts");
26+
27+function d1(db: DatabaseSync) {
28+ const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v);
29+ const statement = (sql: string, params: unknown[] = []) => ({
30+ sql,
31+ params,
32+ bind: (...args: unknown[]) => statement(sql, args.map(value)),
33+ async first<T>() {
34+ return (db.prepare(sql).get(...(params as never[])) as T) ?? null;
35+ },
36+ async all<T>() {
37+ return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true };
38+ },
39+ async run() {
40+ const r = db.prepare(sql).run(...(params as never[]));
41+ return { success: true, meta: { changes: Number(r.changes) } };
42+ },
43+ });
44+ return {
45+ prepare: (sql: string) => statement(sql),
46+ async batch(statements: ReturnType<typeof statement>[]) {
47+ return statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) }));
48+ },
49+ };
50+}
51+
52+const PROJECT = "prj_web";
53+const SCRIPT = "web-acme";
54+const TOKEN = "build-token";
55+const DIGEST = "b".repeat(64);
56+const IMAGE = `registry.cloudflare.com/acct/g1t-${SCRIPT}@sha256:${DIGEST}`;
57+
58+function world(containers: string) {
59+ const sqlite = new DatabaseSync(":memory:");
60+ const dir = new URL("../migrations/", import.meta.url);
61+ for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8"));
62+ const project = {
63+ id: PROJECT,
64+ workspace: "acme",
65+ slug: "web",
66+ name: "web",
67+ primary: true,
68+ source: { kind: "hosted", repoId: "rep_web", repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: null },
69+ };
70+ const ok = (value: unknown) => ({ ok: true, value });
71+ const service = (answer: (method: string, args: any) => unknown) => ({
72+ async fetch(input: string | Request, init?: RequestInit) {
73+ const url = typeof input === "string" ? input : input.url;
74+ const method = new URL(url).pathname.split("/").pop()!;
75+ const args = init?.body ? JSON.parse(String(init.body)) : {};
76+ return Response.json((await answer(method, args)) ?? ok(null));
77+ },
78+ });
79+ const state = { sqlite, charged: [] as any[], pending: [] as any[] };
80+ const env = {
81+ DB: d1(sqlite),
82+ CLOUDFLARE_API_TOKEN: "test",
83+ CLOUDFLARE_ACCOUNT_ID: "acct",
84+ DISPATCH_NAMESPACE: "g1t-deployments",
85+ SITE: "https://g1t.sh",
86+ CONTAINERS: containers,
87+ REPOS: service(() => ok({ isPrivate: true })),
88+ PROJECTS: service((method) => (method === "by_repo" ? [project] : ok(project))),
89+ IDENTITY: service(() => ok(null)),
90+ BILLING: service((method, args) => {
91+ if (method === "prices") return { prices: [] };
92+ if (method === "charge_feature") state.charged.push(args);
93+ if (method === "note_pending") state.pending.push(args);
94+ return ok(true);
95+ }),
96+ WORK: service(() => ok(null)),
97+ ACTIONS: service(() => ({ secrets: { DATABASE_URL: "postgres://db" }, variables: { NODE_ENV: "production" } })),
98+ RUNNER: service(() => ok(true)),
99+ };
100+ return { state, env };
101+}
102+
103+let w: ReturnType<typeof world>;
104+let calls: string[] = [];
105+let uploads: Record<string, any>[] = [];
106+let applications: { id: string; name: string }[] = [];
107+let registryHas = true;
108+const realFetch = globalThis.fetch;
109+
110+function setUp(containers: string) {
111+ w = world(containers);
112+ calls = [];
113+ uploads = [];
114+ applications = [];
115+ registryHas = true;
116+ globalThis.fetch = (async (input: string | Request, init?: RequestInit) => {
117+ const url = new URL(typeof input === "string" ? input : input.url);
118+ const method = init?.method ?? "GET";
119+ calls.push(`${method} ${url.host === "api.cloudflare.com" ? url.pathname.replace("/client/v4/accounts/acct", "") : url.host + url.pathname}`);
120+ const result = (value: unknown) => Response.json({ success: true, result: value });
121+ if (url.host === "registry.cloudflare.com") return new Response(null, { status: registryHas ? 200 : 404 });
122+ if (url.pathname.endsWith("/credentials")) return result({ username: "v1", password: "push-secret", registry_host: "registry.cloudflare.com" });
123+ if (method === "PUT" && url.pathname.includes("/scripts/")) {
124+ const form = init?.body as FormData;
125+ uploads.push(JSON.parse(String(form.get("metadata"))));
126+ return result({});
127+ }
128+ if (url.pathname.endsWith("/durable_objects/namespaces")) {
129+ return result([{ id: "ns1", class: "G1tApp", script: SCRIPT, dispatch_namespace: "g1t-deployments" }]);
130+ }
131+ if (url.pathname.endsWith("/containers/applications") && method === "GET") return result(applications);
132+ if (url.pathname.endsWith("/containers/applications") && method === "POST") {
133+ applications.push({ id: "app1", name: JSON.parse(String(init?.body)).name });
134+ return result({ id: "app1" });
135+ }
136+ if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } });
137+ return result([]);
138+ }) as typeof fetch;
139+ const db = w.state.sqlite;
140+ db.prepare(
141+ `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at, build_as, instance_type)
142+ VALUES (?, 'rep_web', 'acme', 'web', 1, 1, 1, 7, '2026-10-12T00:00:00Z', 'docker', 'basic')`,
143+ ).run(PROJECT);
144+ open("dpl_1");
145+}
146+
147+/** A build under way, as `open` leaves it. */
148+function open(id: string) {
149+ w.state.sqlite
150+ .prepare(
151+ `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, commit_sha, script, status, token_hash,
152+ trusted, created_by, created_at)
153+ VALUES (?, ?, 'acme', 'web', 'rep_web', 'acme/web', 'production', 'abc1234', ?, 'building', ?, 1, 'g1t', ?)`,
154+ )
155+ .run(id, PROJECT, SCRIPT, createHash("sha256").update(TOKEN).digest("hex"), new Date().toISOString());
156+}
157+
158+beforeEach(() => setUp("on"));
159+
160+process.on("exit", () => {
161+ globalThis.fetch = realFetch;
162+});
163+
164+async function post(path: string, body: unknown): Promise<any> {
165+ const response = await worker.fetch(
166+ new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }),
167+ w.env as never,
168+ {} as never,
169+ );
170+ return { status: response.status, ...(await response.json()) };
171+}
172+
173+const rows = (sql: string, ...params: unknown[]) =>
174+ (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row }));
175+
176+const finish = (container: unknown) =>
177+ post("/jobs/dpl_1/finish", { token: TOKEN, worker: {}, detected: "container", framework: "node", container, buildSeconds: 20 });
178+
179+test("while containers are off, the registry step says so in its own words", async () => {
180+ setUp("off");
181+ const answer = await post("/jobs/dpl_1/registry", { token: TOKEN });
182+ assert.equal(answer.status, 409);
183+ assert.equal(answer.error.code, "not_enabled");
184+ assert.ok(!calls.some((call) => call.includes("credentials")), "no login is minted");
185+});
186+
187+test("a build gets a push-only login for its app's repository, held to its size", async () => {
188+ const answer = await post("/jobs/dpl_1/registry", { token: TOKEN });
189+ assert.deepEqual(answer.value, {
190+ registry: "registry.cloudflare.com",
191+ repository: `acct/g1t-${SCRIPT}`,
192+ tag: "dpl_1",
193+ username: "v1",
194+ password: "push-secret",
195+ maxImageBytes: 4_000_000_000,
196+ instanceType: "basic",
197+ });
198+ // Another build's token gets nothing.
199+ assert.equal((await post("/jobs/dpl_1/registry", { token: "nope" })).status, 404);
200+});
201+
202+test("an image outside the app's repository, or not in the registry, is never run", async () => {
203+ const other = await finish({ image: `registry.cloudflare.com/acct/g1t-other@sha256:${DIGEST}`, port: 3000 });
204+ assert.deepEqual(other.value, false);
205+ assert.match(rows("SELECT error FROM deployments WHERE id = 'dpl_1'")[0].error, /did not report an image by digest/);
206+ assert.equal(uploads.length, 0);
207+ open("dpl_2");
208+ registryHas = false;
209+ const missing = await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE } });
210+ assert.deepEqual(missing.value, false);
211+ assert.match(rows("SELECT error FROM deployments WHERE id = 'dpl_2'")[0].error, /is not in the registry/);
212+});
213+
214+test("a container app runs behind g1t's Worker, is metered by its own token, and comes down whole", async () => {
215+ const done = await finish({ image: IMAGE, port: 3000, sizeBytes: 123 });
216+ assert.deepEqual(done.value, true);
217+ // g1t's Worker, its class, and the project's entries bound to it.
218+ const [metadata] = uploads;
219+ assert.deepEqual(metadata.exports, { G1tApp: { type: "durable-object", storage: "sqlite" } });
220+ const binding = (name: string) => metadata.bindings.find((b: any) => b.name === name);
221+ assert.equal(binding("G1T_PORT").text, "3000");
222+ assert.equal(binding("G1T_INSTANCE").text, "basic");
223+ assert.equal(binding("G1T_METER_URL").text, `https://api.g1t.sh/deployments/apps/${SCRIPT}/meter`);
224+ assert.deepEqual(binding("DATABASE_URL"), { type: "secret_text", name: "DATABASE_URL", text: "postgres://db" });
225+ assert.ok(calls.includes("POST /containers/applications"));
226+ assert.deepEqual(rows("SELECT runtime, container_app_id, instance_type FROM apps"), [{ runtime: "container", container_app_id: "app1", instance_type: "basic" }]);
227+ assert.deepEqual(rows("SELECT detected, framework, image, status FROM deployments WHERE id = 'dpl_1'"), [
228+ { detected: "container", framework: "node", image: IMAGE, status: "ready" },
229+ ]);
230+ // The next deploy rolls the application out to the new image.
231+ open("dpl_2");
232+ await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE, port: 3000 } });
233+ assert.ok(calls.includes("POST /containers/applications/app1/rollouts"));
234+
235+ // Its reports: only with the token it was deployed with, each counter only raised.
236+ const token = uploads.at(-1)!.bindings.find((b: any) => b.name === "G1T_METER_TOKEN").text;
237+ const month = new Date().toISOString().slice(0, 7);
238+ const report = { token, epoch: "epoch-0001", month, awakeSeconds: 3600, egressBytes: 1_000_000_000, instanceType: "basic" };
239+ assert.equal((await post(`/apps/${SCRIPT}/meter`, { ...report, token: "wrong" })).status, 404);
240+ assert.equal((await post(`/apps/${SCRIPT}/meter`, report)).status, 200);
241+ await post(`/apps/${SCRIPT}/meter`, { ...report, awakeSeconds: 60 });
242+ assert.deepEqual(rows("SELECT awake_seconds, egress_bytes FROM container_meters"), [{ awake_seconds: 3600, egress_bytes: 1_000_000_000 }]);
243+
244+ // It is noted for the spend limit as it runs: basic for an hour, and 1 GB at $0.05.
245+ w.state.sqlite.exec("INSERT OR IGNORE INTO meters (namespace, month) VALUES ('acme', strftime('%Y-%m', 'now'))");
246+ await worker.scheduled({} as never, w.env as never);
247+ const pending = w.state.pending.find((p) => p.workspace === "acme");
248+ const second = 0.25 * 20 + 1 * 2.5 + 4 * 0.07 + 1.5625;
249+ assert.equal(pending.costMicros, Math.ceil(second * 3600 + 50_000));
250+ assert.match(pending.detail, /1 container hours and 1 GB sent/);
251+
252+ // Taken down: its container first, then its Worker.
253+ calls = [];
254+ await post("/rpc/take_down", { actor: { id: "usr_1", username: "a", kind: "user", workspaces: [{ slug: "acme", role: "owner" }] }, project: { workspace: "acme", slug: "web" }, branch: null });
255+ const deleteApp = calls.indexOf("DELETE /containers/applications/app1");
256+ const deleteScript = calls.findIndex((call) => call.startsWith("DELETE /workers/dispatch/namespaces/g1t-deployments/scripts/web-acme"));
257+ assert.ok(deleteApp >= 0 && deleteScript > deleteApp, calls.join("\n"));
258+});
259+
260+test("a paused container app keeps its class and stops serving", async () => {
261+ await finish({ image: IMAGE, port: 3000 });
262+ uploads = [];
263+ // Its workspace reached its limit.
264+ w.env.BILLING = {
265+ async fetch(input: string | Request, init?: RequestInit) {
266+ const method = new URL(typeof input === "string" ? input : input.url).pathname.split("/").pop();
267+ if (method === "check_limit") return Response.json({ ok: true, value: { state: "stopped" } });
268+ if (method === "prices") return Response.json({ prices: [] });
269+ return Response.json({ ok: true, value: true, init: !!init });
270+ },
271+ } as never;
272+ await worker.scheduled({} as never, w.env as never);
273+ const paused = uploads.find((u) => u.bindings.some((b: any) => b.name === "G1T_MODE" && b.text === "paused"));
274+ assert.ok(paused, "the Worker was put back paused");
275+ assert.deepEqual(paused.exports, { G1tApp: { type: "durable-object", storage: "sqlite" } });
276+ assert.ok(!paused.bindings.some((b: any) => b.name === "DATABASE_URL"), "a paused app holds no secrets");
277+ assert.ok(rows("SELECT paused_at FROM apps")[0].paused_at);
278+});
+228−0
1+/**
2+ * `@g1t/contracts` imports its modules without extensions, so a resolve
3+ * hook adds them, as in moves.flow.test.ts.
4+ */
5+import { test } from "node:test";
6+import assert from "node:assert/strict";
7+import { registerHooks } from "node:module";
8+
9+registerHooks({
10+ resolve(specifier, context, next) {
11+ try {
12+ return next(specifier, context);
13+ } catch (error) {
14+ if (specifier.startsWith(".")) return next(`${specifier}.ts`, context);
15+ throw error;
16+ }
17+ },
18+});
19+
20+const { DEPLOYMENT_COSTS } = await import("@g1t/contracts");
21+const {
22+ APP_WORKER,
23+ appWorkerMetadata,
24+ applicationRequest,
25+ containerCost,
26+ containerOffer,
27+ maxImageBytes,
28+ meterReport,
29+ pushLoginRequest,
30+ pushedImage,
31+ repositoryFor,
32+ rolloutRequest,
33+} = await import("./containers.ts");
34+
35+const COSTS = DEPLOYMENT_COSTS.container;
36+const DIGEST = "a".repeat(64);
37+
38+test("a build gets a push-only login, for minutes, and pushes to its app's repository", () => {
39+ assert.deepEqual(pushLoginRequest(), { permissions: ["push"], expiration_minutes: 15 });
40+ assert.equal(repositoryFor("acc", "web-acme"), "acc/g1t-web-acme");
41+ assert.equal(maxImageBytes("lite"), 2_000_000_000);
42+ assert.equal(maxImageBytes("standard-4"), 20_000_000_000);
43+ assert.equal(maxImageBytes("huge"), 2_000_000_000, "an unknown size is the smallest");
44+});
45+
46+test("only an image by digest in the app's own repository is run", () => {
47+ const repo = "acc/g1t-web-acme";
48+ const ok = pushedImage({ image: `registry.cloudflare.com/${repo}@sha256:${DIGEST}`, port: 3000, sizeBytes: 12 }, repo);
49+ assert.deepEqual(ok, { image: `registry.cloudflare.com/${repo}@sha256:${DIGEST}`, digest: `sha256:${DIGEST}`, port: 3000, sizeBytes: 12 });
50+ // Another app's repository, another registry, a tag, or no report.
51+ assert.equal(pushedImage({ image: `registry.cloudflare.com/acc/g1t-other@sha256:${DIGEST}` }, repo), null);
52+ assert.equal(pushedImage({ image: `docker.io/${repo}@sha256:${DIGEST}` }, repo), null);
53+ assert.equal(pushedImage({ image: `registry.cloudflare.com/${repo}:latest` }, repo), null);
54+ assert.equal(pushedImage(null, repo), null);
55+ assert.equal(pushedImage({ image: `registry.cloudflare.com/${repo}@sha256:${DIGEST}`, port: 0 }, repo)?.port, 8080);
56+});
57+
58+test("the app's Worker is g1t's, with its settings and the project's entries as bindings", () => {
59+ const metadata = appWorkerMetadata(
60+ { mode: "serve", port: 3000, instanceType: "basic", meterUrl: "https://api.g1t.sh/deployments/apps/web/meter", meterToken: "t0ken" },
61+ { secrets: { DATABASE_URL: "postgres://x", G1T_MODE: "sneaky" }, variables: { NODE_ENV: "production", DATABASE_URL: "shadowed" } },
62+ ["workspace:acme"],
63+ );
64+ const bindings = metadata.bindings as { type: string; name: string; text?: string; class_name?: string }[];
65+ const named = (name: string) => bindings.filter((b) => b.name === name);
66+ assert.deepEqual(named("APP"), [{ type: "durable_object_namespace", name: "APP", class_name: "G1tApp" }]);
67+ assert.deepEqual(named("G1T_MODE"), [{ type: "plain_text", name: "G1T_MODE", text: "serve" }], "a project's entry never replaces g1t's");
68+ assert.deepEqual(named("G1T_METER_TOKEN"), [{ type: "secret_text", name: "G1T_METER_TOKEN", text: "t0ken" }]);
69+ assert.deepEqual(named("DATABASE_URL"), [{ type: "secret_text", name: "DATABASE_URL", text: "postgres://x" }], "a secret wins over a variable");
70+ assert.deepEqual(named("NODE_ENV"), [{ type: "plain_text", name: "NODE_ENV", text: "production" }]);
71+ assert.deepEqual(metadata.exports, { G1tApp: { type: "durable-object", storage: "sqlite" } });
72+ assert.deepEqual(metadata.containers, [{ class_name: "G1tApp" }]);
73+});
74+
75+test("the container application runs one instance of the app's image at its size", () => {
76+ const image = `registry.cloudflare.com/acc/g1t-web@sha256:${DIGEST}`;
77+ assert.deepEqual(applicationRequest("g1t-web", image, "basic", "ns1"), {
78+ name: "g1t-web",
79+ scheduling_policy: "default",
80+ configuration: { image, instance_type: "basic" },
81+ instances: 0,
82+ max_instances: 1,
83+ durable_objects: { namespace_id: "ns1" },
84+ });
85+ const rollout = rolloutRequest(image, "nonsense", "dpl_1");
86+ assert.deepEqual(rollout.target_configuration, { image, instance_type: "lite" });
87+ assert.equal(rollout.step_percentage, 100);
88+});
89+
90+test("an app's reports are read strictly", () => {
91+ const good = { token: "t", epoch: "0b8f2c4e-1", month: "2026-10", awakeSeconds: 61.2, egressBytes: 10, instanceType: "basic" };
92+ assert.deepEqual(meterReport(good), { ...good, awakeSeconds: 62 });
93+ assert.equal(meterReport({ ...good, month: "October" }), null);
94+ assert.equal(meterReport({ ...good, awakeSeconds: -1 }), null);
95+ assert.equal(meterReport({ ...good, awakeSeconds: 40 * 24 * 3600 }), null);
96+ assert.equal(meterReport({ ...good, epoch: "x" }), null);
97+ assert.equal(meterReport({ ...good, instanceType: "huge" })?.instanceType, "lite");
98+});
99+
100+test("container time costs what Cloudflare charges at most, and the margin goes on top", () => {
101+ // lite: 1/16 vCPU, 0.25 GiB, 2 GB, and the Durable Object, a second.
102+ const second = (1 / 16) * 20 + 0.25 * 2.5 + 2 * 0.07 + 1.5625;
103+ const hour = containerCost([{ instance_type: "lite", awake_seconds: 3600, egress_bytes: 0 }], COSTS);
104+ assert.equal(hour.micros, Math.ceil(second * 3600));
105+ assert.equal(hour.detail, "1 container hours and 0 GB sent");
106+ const sent = containerCost([{ instance_type: "standard-1", awake_seconds: 0, egress_bytes: 2_000_000_000 }], COSTS);
107+ assert.equal(sent.micros, 100_000, "2 GB at $0.05");
108+ assert.deepEqual(containerCost([], COSTS), { micros: 0, seconds: 0, detail: null });
109+ const offer = containerOffer(COSTS, 20, false);
110+ assert.equal(offer.available, false);
111+ assert.equal(offer.prices[0].type, "lite");
112+ assert.equal(offer.prices[0].hourlyMicros, Math.ceil(second * 3600 * 1.2));
113+ assert.equal(offer.egressGbMicros, 60_000);
114+ // Larger sizes cost more, every one.
115+ for (let i = 1; i < offer.prices.length; i++) assert.ok(offer.prices[i].hourlyMicros > offer.prices[i - 1].hourlyMicros);
116+});
117+
118+/** The app's Worker, loaded with a stand-in for the Workers runtime. */
119+async function appWorker() {
120+ const code = APP_WORKER.replace(
121+ 'import { DurableObject } from "cloudflare:workers";',
122+ "class DurableObject { constructor(ctx, env) { this.ctx = ctx; this.env = env; } }",
123+ );
124+ return (await import(`data:text/javascript,${encodeURIComponent(code)}`)) as {
125+ G1tApp: new (ctx: unknown, env: unknown) => { fetch(r: Request): Promise<Response>; alarm(): Promise<void> };
126+ };
127+}
128+
129+function runtime() {
130+ const stored = new Map<string, unknown>();
131+ const container = {
132+ running: false,
133+ started: null as null | { env: Record<string, string>; enableInternet: boolean },
134+ destroyed: 0,
135+ start(options: { env: Record<string, string>; enableInternet: boolean }) {
136+ this.running = true;
137+ this.started = options;
138+ },
139+ async destroy() {
140+ this.running = false;
141+ this.destroyed++;
142+ },
143+ async setInactivityTimeout() {},
144+ getTcpPort(port: number) {
145+ return { fetch: async () => new Response(`hello from ${port}`) };
146+ },
147+ };
148+ const ctx = {
149+ container,
150+ storage: {
151+ get: async (key: string) => stored.get(key),
152+ put: async (key: string, value: unknown) => void stored.set(key, structuredClone(value)),
153+ setAlarm: async (at: number) => void stored.set("alarm", at),
154+ },
155+ };
156+ return { ctx, container, stored };
157+}
158+
159+test("the app's Worker starts its container with the project's entries, meters it and lets it sleep", async (t) => {
160+ const reports: Record<string, unknown>[] = [];
161+ const realFetch = globalThis.fetch;
162+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
163+ reports.push(JSON.parse(String(init.body)));
164+ return new Response("{}");
165+ }) as typeof fetch;
166+ t.after(() => {
167+ globalThis.fetch = realFetch;
168+ });
169+ const realNow = Date.now;
170+ let now = Date.parse("2026-10-12T10:00:00Z");
171+ Date.now = () => now;
172+ t.after(() => {
173+ Date.now = realNow;
174+ });
175+ const { G1tApp } = await appWorker();
176+ const { ctx, container, stored } = runtime();
177+ const env = {
178+ G1T_MODE: "serve",
179+ G1T_PORT: "3000",
180+ G1T_SLEEP_MS: "300000",
181+ G1T_METER_URL: "https://api.g1t.sh/deployments/apps/web/meter",
182+ G1T_METER_TOKEN: "t0ken",
183+ G1T_INSTANCE: "lite",
184+ APP: {},
185+ DATABASE_URL: "postgres://x",
186+ };
187+ const app = new G1tApp(ctx, env);
188+ const response = await app.fetch(new Request("https://web.g1t.page/"));
189+ assert.equal(await response.text(), "hello from 3000");
190+ assert.deepEqual(container.started, { env: { PORT: "3000", DATABASE_URL: "postgres://x" }, enableInternet: true });
191+ // The first minute is charged as it starts.
192+ assert.equal(reports.at(-1)?.awakeSeconds, 60);
193+ assert.equal(reports.at(-1)?.token, "t0ken");
194+ assert.equal(stored.get("alarm"), now + 60_000);
195+ // A minute on, with a request in it: another minute.
196+ now += 60_000;
197+ await app.alarm();
198+ assert.equal(reports.at(-1)?.awakeSeconds, 120);
199+ assert.equal(reports.at(-1)?.egressBytes, "hello from 3000".length);
200+ // Five minutes without a request: it is stopped, and nothing more is charged.
201+ now += 5 * 60_000;
202+ await app.alarm();
203+ assert.equal(container.running, false);
204+ assert.equal(reports.at(-1)?.awakeSeconds, 120);
205+ // The next request starts it again, from the minute it starts.
206+ now += 60 * 60_000;
207+ await app.fetch(new Request("https://web.g1t.page/"));
208+ assert.equal(reports.at(-1)?.awakeSeconds, 180);
209+ // A new month starts again at zero, under the same counter.
210+ const epoch = reports.at(-1)?.epoch;
211+ now = Date.parse("2026-11-01T00:00:30Z");
212+ await app.alarm();
213+ assert.equal(reports.at(-1)?.month, "2026-11");
214+ assert.equal(reports.at(-1)?.epoch, epoch);
215+ assert.equal(reports.at(-2)?.month, "2026-10", "the month that ended is reported first");
216+});
217+
218+test("a paused app answers with a notice and its container stops", async () => {
219+ const { G1tApp } = await appWorker();
220+ const { ctx, container } = runtime();
221+ container.running = true;
222+ const app = new G1tApp(ctx, { G1T_MODE: "paused" });
223+ const response = await app.fetch(new Request("https://web.g1t.page/"));
224+ assert.equal(response.status, 402);
225+ assert.equal(container.destroyed, 1);
226+ const moved = await new G1tApp(ctx, { G1T_MODE: "redirect", G1T_REDIRECT: "new-acme.g1t.page" }).fetch(new Request("https://web.g1t.page/a?b=1"));
227+ assert.equal(moved.headers.get("location"), "https://new-acme.g1t.page/a?b=1");
228+});
+410−0
Loading the diff…
+314−32
Loading the diff…
+3−0
Loading the diff…
+11−2
Loading the diff…
+3−0
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.