# Deploy on git push

> Build and deploy on every push to main with Workers Builds or GitHub Actions — pick exactly one — plus custom domains, the trailing-slash rule, and rolling back.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/deploy/git-push/

---

After [Go live](https://zerodirs.com/docs/deploy/go-live/), deploys should happen without you: a push to `main` builds and
publishes, and an approval in `/admin/` does the same. Two routes exist, and you should pick exactly
one — running both is how a build from an old commit overwrites a newer deploy.

| | Workers Builds | GitHub Actions |
| --- | --- | --- |
| Where it runs | Cloudflare's builder, connected to your repository | your repository's Actions |
| Tokens you manage | none — Cloudflare mints its own | three repository secrets |
| What an approval triggers | a Deploy Hook (`providers.rebuild: 'deploy-hook'`) | a `repository_dispatch` event (`providers.rebuild: 'github-dispatch'`) |
| Choose it when | you want the least setup | you already deploy everything from Actions, or you want tests to run before every deploy |

## Setup

**Workers Builds**

1. Connect the repository.

   Cloudflare dashboard → Workers & Pages → your Worker → **Settings → Build → Connect**, and fill
   in:

   | Field | Value |
   | --- | --- |
   | Repository / branch | your repository, `main` |
   | Root directory | the directory holding `package.json` and `wrangler.jsonc` — `/` for a flat copy |
   | Build command | `pnpm build` |
   | Deploy command | `pnpm exec wrangler deploy` — the pinned wrangler, not whatever `npx` fetches today |
   | API token | leave empty; Cloudflare generates one |

2. Add the build variables.

   Under **Variables and secrets** on the same page. For a site that still builds from Markdown,
   `PNPM_VERSION=10.12.4` is enough. Once the site reads the database, add `LISTINGS_SOURCE=d1`,
   `CLOUDFLARE_ACCOUNT_ID`, `D1_DATABASE_ID`, and `D1_READ_TOKEN` as a **secret** — see
   [Environment and secrets](https://zerodirs.com/docs/deploy/environment/#build-time-variables).

3. Push to `main`.

   Cloudflare builds and deploys. Other branches get a preview version and never touch production.

Versions are pinned by files already in the repository: `.nvmrc`, `packageManager` in
`package.json`, and wrangler in `devDependencies`. A build takes about a minute.

Free-plan quota: 3,000 build minutes a month, 20 minutes per build, and **one concurrent build per
account**. The concurrency limit is account-wide, so a second connected project makes builds queue —
including the build an approval triggers.

**GitHub Actions**

A `deploy.yml` ships with the starter and is **off until you set a repository variable**. It shows
as skipped otherwise, so the file costs nothing and a repository cannot end up with two things
deploying the same Worker.

1. Create three Cloudflare tokens and store them as repository secrets.

   | Secret | Scope | Can it… |
   | --- | --- | --- |
   | `CLOUDFLARE_API_TOKEN` | Edit Cloudflare Workers | deploy code to your domain |
   | `CLOUDFLARE_D1_WRITE_TOKEN` | D1 Edit only | change the schema |
   | `D1_READ_TOKEN` | D1 Read only | neither — and it is the only one the build itself gets |

   Three is the point: a leaked build log cannot deploy, and a leaked deploy token cannot drop a
   table.

2. Switch the workflow on.

   ```sh title="Terminal"
   gh variable set USE_GITHUB_DEPLOY --body true
   ```

3. Push to `main`.

   The workflow runs `pnpm check`, `pnpm test` and the build before it deploys, so a failing test
   never reaches production.

If you set `providers.rebuild: 'github-dispatch'`, this workflow is also what receives the rebuild
event when you approve a listing — without it, approving publishes nothing.

## Verify

| | Where | You should see |
| --- | --- | --- |
| The build ran | Workers Builds → **Deployments**, or the Actions run | a green build, about a minute long |
| It built from the right source | the build log | `[zerodirs-files] listings: N from ./src/content/listings` or `[zerodirs-d1] D1: N approved listings` — whichever you intended |
| It is live | `https://<your domain>/` | the change you pushed |

## Custom domains

1. Add the domain to your Cloudflare account as a zone.

2. Workers & Pages → your Worker → **Settings → Domains & Routes → Add → Custom domain**. Cloudflare
   issues the certificate and creates the DNS record. `pnpm cf:setup` does this step for you when
   `site.url` is on a zone the account owns.

3. Set `site.url` to that domain and **deploy again**. Canonicals, the sitemap and the feeds are
   built from it, so until the next build they still point at the old host.

The `<name>.workers.dev` URL keeps working. Turn it off in the same panel if you would rather it did
not — and do turn it off if you put the admin routes behind Cloudflare Access, because
`*.workers.dev` bypasses a zone-level Access policy. See [Security](https://zerodirs.com/docs/run/security/).

## Every dynamic URL ends in a slash

`trailingSlash: 'always'` is enforced before route matching, so a request to a URL without one earns
a 301 (GET) or 308 (everything else). That is invisible for a browser and fatal for a webhook: Stripe
records the redirect as a **failed delivery**. Register the endpoint with the slash.

```text
https://example.com/api/stripe/webhook/     ← correct
https://example.com/api/stripe/webhook      ← 308, recorded as a delivery failure
```

The same applies to the Deploy Hook URL, the checkout redirect, the status-page links and every
admin route. Nothing in the starter constructs a URL by hand: `src/lib/paths.ts` is the only place
one is built, and a unit test asserts every dynamic path it produces ends in `/`.

## Rollback

Every deploy is a version. Workers & Pages → your Worker → **Deployments** → pick the previous one →
Rollback. Nothing local is needed.

This is also why the build is strict rather than forgiving. An unknown category, an orphan page under
`seo.strictLinks`, or a file count over 90% of the plan limit all stop the build — and the version
already serving keeps serving. A build that fails is a deploy that did not happen, which is the
outcome you want.

> **site.url is baked in at build time**
>
> `astro.config.ts` passes it to Astro as `site`, and canonicals, the sitemap, the RSS feeds and the
> absolute OG image URLs all derive from it. Building with someone else's value publishes canonical
> tags pointing at their domain, which search engines follow — and your pages drop out of the index.

## Related

- [Publishing on approval](https://zerodirs.com/docs/submissions/publishing/) — The Deploy Hook or dispatch event an approval fires, and how to see that it arrived.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/) — Build variables versus runtime secrets, and where each one is set.
- [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) — The four build variables that make a push build from the database.
- [Troubleshooting](https://zerodirs.com/docs/run/troubleshooting/) — Canonicals pointing at the wrong domain, a deploy that looks different from the build.
