Self-hosting
One build, one directory, any static host — or a USB stick.
Pagecraft’s build output is a directory of files. There is no server, no database, no runtime, no adapter and no environment to provision, because every document operation happens in the visitor’s own browser and there is nothing left for a backend to do.
git clone https://github.com/amanawasthi2025/pagecraft
cd pagecraft
pnpm install
pnpm build
apps/web/dist is the site. Serve it.
Nothing in that sequence needs an account with anybody. The install reads the public npm registry and nothing else — no font CDN, no analytics vendor, no error tracker, no image pipeline, no API key for a build service — and what comes out needs no account to run, because there is nothing for it to sign in to. The only third party in the whole arrangement is whoever serves the files, and that can be a laptop.
Every release also carries a pagecraft-site-v<version>.tar.gz on its release
page. Read what it is before you serve it: that is the site built for
pagecraft-pdf.pages.dev, and the address is baked into every page of it (below). It is
there to be checked rather than deployed — build the tag yourself and the two
directories are the same bytes, which is how you find out whether what is being
served at pagecraft-pdf.pages.dev is what this repository says it is. If you are hosting
at your own address, build your own.
Point it at your own address
Absolute URLs are baked in at build time — the canonical link, the hreflang
links, the structured data and the social cards are all absolute, and a build
with nowhere to point them would produce two hundred pages quietly claiming to
be somewhere else. So the address is a build input:
PAGECRAFT_SITE_URL=https://pdf.example.org pnpm build
Without it the build uses https://pagecraft-pdf.pages.dev, which is right for exactly
one deployment and wrong for yours. Set it.
Serving it
Anything that serves files will do. What is worth getting right is three things: the headers, the page for a URL that is not a page, and the cache.
The headers. dist/_headers is written by the build, in the format the
common static hosts read. It carries the content security policy, the security
headers, and immutable caching for the content-hashed assets. Hosts that read
that file get all of it for free. Hosts that do not still get the identical
policy from a <meta> tag on every page, which is why the site works correctly
on a host that ignores every word of this.
The URL. A page is built as merge-pdf.html and served at /merge-pdf,
which is what the static hosts expect and resolve for themselves. A plain web
server has to be told, and it is the one thing that silently 404s every page on
the site if you skip it:
# nginx
try_files $uri $uri.html $uri/index.html =404;
# Caddy
try_files {path}.html {path} {path}/index.html
The extension is tried before the bare path on purpose: /docs is docs.html,
and a server that looked for the directory of that name first would find the
guides inside it and no page to show.
The wrong URL. dist/404.html sits where every static host looks for it,
and dist/_redirects names a page per language for hosts that read one. A
plain web server needs one line:
# nginx
error_page 404 /404.html;
# Caddy
handle_errors { rewrite * /404.html; file_server }
The cache. Everything under /_astro/, /pdfjs/ and /capabilities/
carries a content hash or a version in its name and can be cached forever.
sw.js must never be cached — a stale service worker is a site that can never
be updated. _headers says both.
In a container
There is a Dockerfile at the root of the repository. It builds the site from
source and serves it with Caddy:
docker build --build-arg PAGECRAFT_SITE_URL=https://pdf.example.org -t pagecraft .
docker run --rm -p 8080:8080 pagecraft
Nothing is baked into it by hand. The image runs scripts/caddyfile.mjs over
the directory it is about to serve and configures the server from the _headers
and _redirects that the build just wrote, so the container sends the same
policy as every other host and cannot drift from it — the policy contains a hash
of every inline script on the site and changes whenever the site does.
$PORT overrides the port, which is what the hosts that choose one for you set.
The server runs as an unprivileged user and writes nothing — TLS belongs to
whatever is in front of it — and there is nothing behind it: no database, no
cache, no API, because there is no work for a server to do.
If you would rather run your own server against the same directory:
pnpm caddyfile --output Caddyfile
and read it. Eighty-odd lines, most of them comments carried over from the files it was generated out of, and it is the whole of what a host has to be told.
On a machine with no internet
The site is installable and precaches itself, so after one visit it works with
the network unplugged. If the machine never has a network at all, build
elsewhere and copy dist — it is self-contained. Nothing in it fetches from a
third-party origin at runtime; there is no font CDN, no analytics script and no
telemetry endpoint to fail.
Choosing what to ship
Two things are worth knowing before you build.
AI providers are admitted at build time. The content security policy names
the origins a page may talk to, and a policy is written before anybody has
pasted a key and cannot be replaced afterwards. A build that admits no
providers is a build where the AI features cannot reach anything — which is a
perfectly reasonable thing to ship inside an organisation that has decided
against them. See apps/web/src/ai/origins.ts.
Downloadable capabilities are large. Fonts for scripts the standard
fourteen cannot set, and the recognition engine for OCR, are megabytes that a
visitor chooses to download. They are built into dist/capabilities/ and served
from your origin like everything else, so nobody’s browser has to ask a third
party for them.
Keeping it
- Track
mainor a tag. Pagecraft is pre-1.0; there are no maintained release branches yet. - Rebuild to update. The service worker’s build id is a hash of every file
in
dist, so two builds of the same source are the same build and a visitor who is already current is not made to download the site again. A build with one changed paragraph is a new id, and everybody gets the paragraph. - That claim is checked, not asserted.
pnpm build && pnpm check:reproduciblebuilds the whole thing a second time into an emptied directory and compares it file by file. It runs in CI on every push, which is also what lets you take a release tarball, build the tag it came from, and find that the two are the same bytes — the only way to verify what you are being served without taking anybody’s word for it. - Check
pnpm checkpasses on your fork before you deploy it. It is the same command CI runs.
The licence, if you are changing it
apps/web is AGPL-3.0-or-later. Running a modified copy where other people can
reach it means publishing your changes. That is the deal, and it is the deal
because the alternative is somebody re-skinning this and selling it.
If you want to run a modified or white-labelled copy without publishing your
changes, there is a commercial licence — that is the whole business model, and
asking is a normal thing to do. The engine in packages/* is MIT and has none
of this attached: embed it in anything.
Where to go next
- Architecture — what the build actually produces, and how to audit the zero-upload claim on your own deployment.
- The command line — if all you wanted was the tools, without a site.