Deployment

Build both bundles, then serve the result:

deno task build     # client + SSR build
deno task start     # serve the build

Serves on port 8000. To change it, run deno serve directly with --port before the entry:

deno serve -A --port 3000 server.prod.ts

The production entry

server.prod.ts serves static files from dist/client and falls through to the app's SSR routes. Content-hashed files under /assets/ are pinned forever; public/ files keep stable names, so they revalidate.

import app from "./dist/server/server.mjs";
import { serveStatic } from "chevalier/static";

const CLIENT_DIR = new URL("./dist/client", import.meta.url).pathname;
const IMMUTABLE = "public, max-age=31536000, immutable";
const REVALIDATE = "public, no-cache";

export default {
  fetch: serveStatic({
    fsRoot: CLIENT_DIR,
    fallthrough: (req) => app.fetch(req),
    cacheControl: (pathname) =>
      pathname.startsWith("/assets/") ? IMMUTABLE : REVALIDATE,
  }),
};

A CDN in front is still recommended for high traffic.

Deno Deploy

Deploy doesn't run Vite, so build first, then ship dist/ — it's gitignored, so include it explicitly:

deno task build
deno install -Arf jsr:@deno/deployctl
deployctl deploy --include=dist --include=deno.json --entrypoint=server.prod.ts

Docker

The same entry runs in a container, because deno serve takes the very handler Deno Deploy expects — there's no second entrypoint to keep in sync. Build in a full image, where deno install and Vite have a toolchain to work with, then ship the result on a distroless one that carries nothing but the Deno binary and the app:

FROM denoland/deno:debian-2.9.2 AS build
WORKDIR /app
COPY deno.json deno.lock ./
RUN deno install --allow-scripts
COPY . .
RUN deno task build

FROM denoland/deno:distroless-2.9.2
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/deno.json /app/deno.lock ./
COPY server.prod.ts ./
EXPOSE 8000
CMD ["serve", "-A", "--host", "0.0.0.0", "--port", "8000", "server.prod.ts"]

Only three things need to reach the runtime stage: dist/, server.prod.ts, and deno.json alongside deno.lock. That last pair looks like a build leftover, but dropping it breaks the container at startup rather than at build — server.prod.ts imports chevalier/static by its import-map specifier, so Deno reads the manifest to resolve it and the lockfile to pin the version. Pair the build with a .dockerignore that excludes dist/, since the build stage runs deno task build itself and a local build would only be shipped to the daemon to be overwritten.

A compose file runs it, and expose publishes the port to other containers but not to the host — which assumes a reverse proxy in front, where TLS and caching belong:

services:
  docs:
    build: .
    expose:
      - "8000"
    restart: unless-stopped
docker compose up --build

To reach the site directly instead — a local smoke test, or a single-host deploy with nothing in front of it — map the port yourself with docker run -p 8000:8000.