Skip to content
ZenithDocs
English

Deployment

Ship the site as a small container image, or to any static host.

On this page

A ZenithDocs build is a folder of static files in dist/: HTML, CSS, a little JavaScript, the search index and the images. Anything that serves files can host it, with no server-side runtime.

npm run build

Docker

ZenithDocs writes a ready-to-use Dockerfile. Ask for it when creating the site, or add it to an existing one:

New site

npx github:AymericChaverot/zenith-docs create my-docs --docker

Existing site

npx zenith docker

Then build and run the image:

docker build -t my-docs .
docker run --rm -p 8080:8080 my-docs

The site is served at http://localhost:8080.

What is in the image

The Dockerfile has two stages. The first builds the site with Node and is thrown away; only the static files reach the second, which serves them.

Choice Why
nginxinc/nginx-unprivileged:alpine-slim About 30 MB in all, with no Node, no package manager and no build tools
Unprivileged user, port 8080 Runs on platforms that forbid root containers, such as OpenShift
Files compressed at build time nginx sends the .gz next to each file, and never compresses on the fly
immutable cache on /_astro/ Those files have a content hash in their name, so they never change
404.html as the error page Unknown URLs get the site’s own not found page
text/markdown for .md files The Markdown version of each page opens instead of downloading
nosniff and a strict referrer policy Security headers that cost nothing on a static site
A health check Orchestrators can tell when the server is ready

Dependencies are installed before the content is copied, and cached between builds, so editing a page rebuilds the site without reinstalling anything. The package manager follows the lockfile: pnpm when pnpm-lock.yaml exists, npm otherwise.

Serving from a sub-path

With base set to /docs, the files have to sit in a docs folder of the server, and the error page moves with them. Two lines of the Dockerfile change, as its own comment says:

Dockerfile
    error_page 404 /docs/404.html;

COPY --from=build /app/dist /usr/share/nginx/html/docs

Last updated dates

lastUpdated reads the git history, which .dockerignore leaves out of the image to keep builds fast. Remove the .git line from .dockerignore to use it.

Docker Compose

compose.yaml
services:
  docs:
    build: .
    ports:
      - '8080:8080'
    restart: unless-stopped
docker compose up -d --build

Publishing the image

This workflow builds the image on every push to main and publishes it to the GitHub Container Registry, as ghcr.io/<owner>/<repository>:

.github/workflows/image.yml
name: Image

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=raw,value=latest
            type=sha
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

docker/metadata-action also lowercases the image name, which the registry requires.

Static hosts

Netlify, Vercel, Cloudflare Pages and the others all ask for the same settings:

Setting Value
Build command npm run build
Output directory dist
Node.js version 22.18 or later

Set site to the URL the host gives you, so that canonical links, the sitemap and social images use absolute URLs. A host that serves the site from a sub-path, like GitHub Pages without a custom domain, also needs base.

GitHub Pages

A repository named my-docs is served at https://<owner>.github.io/my-docs/, so the site needs both options:

zenith.config.ts
export default defineConfig({
  title: 'My Docs',
  site: 'https://<owner>.github.io',
  base: '/my-docs',
});

Links written from the root in your pages, like [Install](/installation/), get the base added at build time: there is nothing to change in the content.

This workflow builds the site on every push to main and publishes it. In the repository settings, under Pages, set the source to GitHub Actions.

.github/workflows/pages.yml
name: Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # The whole history, for the last updated dates.
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

This site is deployed the same way, from its repository.

Last updated on