Deployment
Build both bundles, then serve the result:
deno task build # client + SSR build
deno task start # serve the buildServes on port 8000. To change it, run deno serve directly with --port before the entry:
deno serve -A --port 3000 server.prod.tsThe 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.tsDocker
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-stoppeddocker compose up --buildTo 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.