Deploy a site

pagedeck build writes a static site, and no pagedeck command uploads it. You upload it to your origin, the bucket or directory your host serves, with the host's own tools. This page covers what to upload, how to upload only the files that changed, how to put an earlier build back, what to keep between CI runs, and how to give your host the site's redirects and headers. The pagedeck command lists every verb, flag and exit code used here.

The commands run in the site's directory, the one holding pagedeck.config.ts. The paths are the defaults of the site npm create pagedeck writes: the site goes to site/, and the content store is content.db. If you have no site yet, Your first site makes one.

1. Upload the output directory

Sync the content into the content store, then build:

npx pagedeck sync
npx pagedeck build

Upload everything in site/. Any host that serves a directory of files can serve it. Do not upload content.db or .pagedeck/: the build reads them, and a visitor has no use for them. They are outside site/ so that copying the whole directory is safe.

A full build deletes from site/ every file the previous build wrote and this build did not. A retracted page is gone from the directory after the next build. A host that mirrors the directory must also delete on its side. For an S3 bucket, aws s3 sync deletes only when you ask it to:

aws s3 sync site/ s3://my-bucket/ --delete

site/manifest.json is part of the upload. It records every file the build wrote and the hash of each, and the next section uses it. A plain static host also serves it to anyone who asks for /manifest.json. The edge artifacts in section 5 answer that request with a 404.

2. Upload only what changed

A full upload is simple, but it sends every file on every deploy. To send only the files that changed, compare the manifest of the build your origin serves with the manifest of the new build:

npx pagedeck diff live-manifest.json site/manifest.json > plan.json

live-manifest.json is the manifest.json your last deploy uploaded. Read it from the origin itself, not from the site's public URL, because the edge artifacts refuse /manifest.json. For an S3 bucket:

aws s3 cp s3://my-bucket/manifest.json live-manifest.json

Or keep the site/manifest.json of each deploy as a CI artifact, and download the last one. The deploy recipe reads it from a bucket with a presigned GET. On a first deploy there is no live manifest: upload the whole of site/ as section 1 does.

pagedeck diff writes a JSON document, the plan. Your upload script reads it:

  • trees has one entry per output tree. A site with no per-locale domains has one. A tree with a domain key is the directory of that name under site/.
  • Each tree's upload lists the files that are new or changed. A row's path is the file's path in its tree: /about/index.html is site/about/index.html. Upload the rows in the order listed. The scripts, stylesheets and images come before the pages, so no page goes live before a file it loads.
  • Each tree's prune lists the files the new build no longer writes. Delete them, but not before the time in the plan's top-level prune.notBefore. A visitor who loaded a page from the old build can still ask for a script that build used. The wait is seven days after the new build was made, and --grace-seconds changes it.
  • stats counts the files added, changed, pruned and unchanged, and the bytes to upload.

Upload site/manifest.json last, after every row. It is the live manifest for the next deploy.

pagedeck diff refuses a build that was not made on top of the live build. Each build records the newest build in .pagedeck/manifests when it started. If that is not the build the origin serves, another deploy happened in between, and this plan would overwrite it. The refusal exits with code 2. Run pagedeck build again so the new build starts from what is live. A build with no record at all is refused the same way, which is what happens on a CI runner that starts with an empty .pagedeck/. Section 4 keeps it.

--force deploys the build anyway. Pass it by hand, for one deploy you have checked. A pipeline that always passes it has turned the check off. Deploy serialization and rollback explains the record and how to keep two deploys from running at once.

3. Roll back

Each build keeps a copy of its manifest in .pagedeck/manifests, as <build id>.json. To put an earlier build back, list them, and pass the name without .json:

ls .pagedeck/manifests
npx pagedeck rollback 3c77b1de-52a0-4e77-bb0e-8f0a1c2d3e40 > rollback.json

pagedeck rollback writes the same kind of plan as pagedeck diff. It compares site/manifest.json, the build your origin serves, with the kept manifest. Run it where site/ holds the live build. Your upload script runs the plan as it runs a deploy. A rollback is always out of order, so it takes no --force.

The plan names files, and the manifest holds no file contents. Upload the rows from the output of the build you are restoring: keep each deployed site/ as a CI artifact, or build that commit again.

The build keeps the 20 newest manifests, and build.retention.keep changes that number. Deploy serialization and rollback covers how far back a rollback reaches.

4. Keep the content store and .pagedeck/ between CI runs

A CI runner starts with no content.db and no .pagedeck/. Without content.db, every run syncs all the content from the source. Without .pagedeck/, every build records no earlier build, and pagedeck diff refuses every deploy.

Keep the content store as a snapshot. pagedeck store pull downloads content.db and pagedeck store push uploads it. Give the target in PAGEDECK_SNAPSHOT_URL and none on the command line, as PAGEDECK_SNAPSHOT_URL explains:

- run: npx pagedeck store pull
  env:
    PAGEDECK_SNAPSHOT_URL: ${{ secrets.SNAPSHOT_URL }}
- run: npx pagedeck sync --incremental
- run: npx pagedeck build
- run: npx pagedeck store push
  env:
    PAGEDECK_SNAPSHOT_URL: ${{ secrets.SNAPSHOT_URL }}

On the first run there is no snapshot to pull, and no cursor for pagedeck sync --incremental to start from. Run pagedeck sync and pagedeck store push once to create both.

Keep .pagedeck/ in your CI's cache. Restore it before the build and save it after. It holds the kept manifests, so it is also what pagedeck rollback reads.

pagedeck build --incremental renders only the pages the content changes touch, and reads the rest from the previous site/. On CI it needs site/ from the last run too, from the same run as the snapshot. A full build does not.

5. Give your host the redirects and headers

build.routing declares the site's redirects, its 404 page and the response headers for each path prefix. Routing covers the fields, and Security headers the headers to set before a deploy. The build compiles them into one host-agnostic routing document first, writing it into site/manifest.json in a form that names no host.

An adapter compiles that document into edge artifacts, the files one host reads. Each host has its own adapter package. Install the one for your host, here Netlify, and name it in build.adapter:

npm install @pagedeck/adapter-netlify
import { defineConfig } from "@pagedeck/core";
import { netlify } from "@pagedeck/adapter-netlify";

export default defineConfig({
  collections: [],
  build: {
    pages: [],
    components: {},
    adapter: netlify(),
  },
});

A new site can skip this step: npm create pagedeck@latest my-site -- --host netlify installs the adapter and writes both of these for a fresh site, and --host vercel or --host cloudflare-pages do the same for those hosts. The above is what to do by hand, or to a site that already exists.

pagedeck build runs the adapter after it plans the routing document and before it reports success. Each edge artifact has a role, and the role says where the build writes it: a tree-file into site/<tree>/, where it rides site/manifest.json and pagedeck diff like any other file, and every other artifact into edge/<tree>/, beside site/, which you install on the host yourself (<tree> is empty for a site with one output tree). An incremental build writes the same files a full build does, and an artifact an earlier build wrote that this one does not is removed from wherever it was written, the way a full build removes a retracted page from site/. pagedeck dev runs no adapter: it renders pages from the store on request and writes neither site/ nor edge/.

For another host, install its adapter and name its factory in build.adapter in place of netlify():

  • netlify() from @pagedeck/adapter-netlify writes _redirects, and _headers when the site declares header rules. Both are tree files, so pagedeck diff uploads them with the rest of the site, after the site's own files, so no redirect goes live before its target.
  • nginx() from @pagedeck/adapter-nginx writes routing.conf into edge/. include it in the server block that serves site/.
  • cloudfront() from @pagedeck/adapter-cloudfront writes a viewer-request and a viewer-response CloudFront Function, and configuration fragments when the routing needs them, into edge/. Publish each function and associate it with the distribution. A function left in edge/ does nothing until you publish it.
  • cloudflareWorker() from @pagedeck/adapter-cloudflare-worker writes worker.js into edge/, published as a Worker with the site's R2 bucket bound to it. This adapter has not yet served a site in production: test it on a staging deploy before you rely on it.
  • cloudflarePages() from @pagedeck/adapter-cloudflare-pages writes _redirects, and _headers when the site declares header rules, the same as Netlify's. Both are tree files. It refuses trailingSlash: "never".
  • vercel() from @pagedeck/adapter-vercel writes vercel.json, a tree file.

Outside pagedeck build — against a manifest from another build, or to try an adapter without building — call it directly over a routing document:

import { readFileSync } from "node:fs";
import { readManifest } from "@pagedeck/core";
import { netlify } from "@pagedeck/adapter-netlify";

const manifest = readManifest(readFileSync("site/manifest.json", "utf8"), "site/manifest.json");
for (const artifact of netlify().compile(manifest.routing).artifacts) {
  console.log(artifact.role, artifact.path);
}

Every adapter's edge artifacts answer /manifest.json and /.pagedeck/ with a 404, even for a site that declares no routing. Install them, and the manifest you uploaded to the origin stays private.

Host guides

Each adapter has a page that takes a site from a build to its host: the host's build settings, where each artifact goes, how to keep the content store between builds, and how to send a path prefix to an app.

Adapter Host Guide
cloudflarePages() Cloudflare Pages, or Workers Static Assets Deploy to Cloudflare Pages
cloudflareWorker() a Cloudflare Worker in front of an R2 bucket Deploy to a Cloudflare Worker
cloudfront() S3 behind CloudFront, with an app on AWS Lambda Deploy to CloudFront
netlify() Netlify Deploy to Netlify
nginx() your own nginx server Deploy to nginx
vercel() Vercel Deploy to Vercel