Dockerizing a TanStack Start app with Nitro
TanStack Start doesn’t bundle a production server. vite build on its own gives you a Fetch-API request handler - a (Request) => Promise<Response> - and no program that binds a port. Nitro is what turns that into a standalone server, and it’s a package you install yourself rather than something that ships in the box.
Once it’s in, vite build writes a .output/ directory that runs with plain node, depends on nothing else, and drops into a container in one COPY.
Versions this was written against: @tanstack/react-start 1.168.46, nitro 3.0.260610-beta, srvx 0.12.5, vite 8.1.4.
Step 1: install Nitro and srvx
pnpm add nitro srvx
Nitro v3 is still in beta, so pin it exactly rather than with a range - the build output is the thing your Dockerfile depends on, and beta minors move:
"nitro": "3.0.260610-beta",
"srvx": "^0.12.5"
srvx is optional. It’s the Fetch-API server layer Nitro uses underneath, and the docs suggest borrowing its FastResponse for “a ~5% throughput improvement” on Node deployments. Step 3 is where that gets wired in - and where the one real trap lives.
Step 2: add the Nitro plugin
// vite.config.ts
import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import viteReact from '@vitejs/plugin-react';
export default defineConfig({
plugins: [tanstackStart(), nitro(), viteReact()]
});
Order matters: nitro() goes after tanstackStart() and before viteReact().
That alone is enough to change the build output. With no further configuration, Nitro picks the node-server preset and writes .output/.
Step 3: the server entry, and the snippet that breaks it
The hosting guide gives you this for src/server.ts:
// src/server.ts - as documented. Do NOT ship this as-is.
import { FastResponse } from 'srvx';
globalThis.Response = FastResponse;
Copy that verbatim into a project that didn’t already have a src/server.ts and the build succeeds, the container starts, and every request 500s:
TypeError: mod.fetch is not a function
at file:///app/.output/server/_chunks/ssr-renderer.mjs:8:36
status: 500,
unhandled: true
The problem is that src/server.ts isn’t a configuration file you sprinkle globals into - it’s the server entry. If it doesn’t exist, TanStack Start supplies a default one. The moment you create it, yours replaces the default wholesale, and the snippet above exports nothing.
You can watch it happen in the build output. Nitro’s SSR service loader does this:
// .output/server/_chunks/ssr-renderer.mjs
function lazyService(loader) {
let promise, mod;
return {
fetch(req) {
if (mod) return mod.fetch(req);
if (!promise) promise = loader().then((_mod) => (mod = _mod.default || _mod));
return promise.then((mod) => mod.fetch(req));
}
};
}
It imports the SSR service and calls .fetch() on its default export. And the SSR service it’s importing - your src/server.ts, compiled - is this in its entirety:
// .output/server/_ssr/ssr.mjs - the whole file
globalThis.Response = NodeResponse;
export {};
No default export, no fetch, so mod.fetch is undefined. The dead giveaway is the file size: a working _ssr/ssr.mjs is a couple hundred KB of your actual application. If yours is three lines, this is why.
The fix is to re-export the handler the default entry would have given you:
// src/server.ts
import { FastResponse } from 'srvx';
import handler from '@tanstack/react-start/server-entry';
globalThis.Response = FastResponse;
export default handler;
The server entry docs spell out the contract the hosting page assumes you already know - the default export has to satisfy { fetch(req: Request): Response | Promise<Response> }. If you want to wrap the handler with your own logic rather than just pass it through, there’s a typed helper:
import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
export default createServerEntry({
fetch(request) {
// your middleware here
return handler.fetch(request);
}
});
Two docs pages, each correct on its own, that combine into a broken file. Worth knowing before you spend an evening on it.
Step 4: the start script
"scripts": {
"build": "vite build",
"start": "node .output/server/index.mjs"
}
What you actually get
.output/
├── nitro.json # preset metadata
├── public/ # client assets, served by the server below
│ └── assets/
└── server/
├── index.mjs # a real http listener
├── _ssr/ssr.mjs # your compiled server entry
├── _chunks/
├── _libs/
└── node_modules/ # just tslib
index.mjs binds a port and serves public/ itself. PORT and HOST are read from the environment; it listens on all interfaces by default.
The part that matters for the image is that this is self-contained. Verify it rather than trusting it - copy .output somewhere with no node_modules anywhere up the tree, and run it:
mkdir /tmp/standalone && cp -r .output /tmp/standalone/
cd /tmp/standalone && PORT=3000 node .output/server/index.mjs
# ➜ Listening on: http://localhost:3000/ (all interfaces)
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 200
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/favicon.svg # 200
SSR and static assets, both from .output alone - no project node_modules, no vite, no src, no package.json. Around 4.5 MB total for a mid-sized app. Nitro inlines the dependency tree into _libs/; the only thing left in .output/server/node_modules is tslib.
That single fact is what makes the Dockerfile trivial.
The Dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22-alpine
ARG PNPM_VERSION=11.17.0
# ---- build ----------------------------------------------------------------
FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# VITE_* vars are inlined into the bundle at build time - a build arg,
# not a runtime env var. One image build per environment.
ARG VITE_API_BASE_URL=https://api.example.com
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN pnpm build
# ---- runtime --------------------------------------------------------------
FROM node:${NODE_VERSION} AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --chown=node:node --from=build /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
Two stages, one COPY in the runtime stage, and no package manager in the final image - CMD invokes node directly. The runtime stage is stock node:22-alpine plus 4.5 MB. Keep --chown=node:node on the COPY so the non-root node user actually owns what it’s running.
Add .output to .dockerignore alongside node_modules and dist - the image should build it fresh, not inherit whatever is sitting in your working tree.
One thing worth knowing if you’re putting this behind a reverse proxy: unlike Vite’s dev and preview servers, Nitro’s node-server output has no Host header allowlist, so proxied domains work without configuration.
curl -o /dev/null -w '%{http_code}\n' -H 'Host: app.example.com' http://localhost:3000/
# 200
Host filtering, if you want it, belongs at the proxy.
GitHub Actions: build and publish to GHCR
name: Build and publish Docker image
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# PRs are built to verify the image compiles, but never pushed.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL || 'https://api.example.com' }}
cache-from: type=gha
cache-to: type=gha,mode=max
docker/metadata-action lowercases the image name automatically, so github.repository’s casing doesn’t matter. GITHUB_TOKEN is enough to push - no PAT, no registry secret. Set a VITE_API_BASE_URL repository variable (Settings → Secrets and variables → Actions → Variables) to control what gets baked into the published image.
Recap
pnpm add nitro, addnitro()to the Vite plugins, andvite buildproduces a standalone.output/server/index.mjs.- Creating
src/server.tsreplaces the default server entry. The hosting guide’sFastResponsesnippet is a fragment, not a file - withoutexport default handleryou getmod.fetch is not a functionon every request. Check_ssr/ssr.mjs: three lines means you hit this. .outputis genuinely standalone, so the runtime stage isnode:22-alpineplus oneCOPY- no package manager, no source, nonode_modules.VITE_*vars are baked in at build time, which makes them build args and means one image per environment. Everything else -PORT,HOST- stays a runtime knob.