How to Deploy TanStack Start with Docker and Bun
Source: Dev.to

TanStack Start is quickly becoming the go‑to framework for developers who want the power of TanStack Router with full‑stack capabilities. It’s bleeding‑edge, it’s fast, and the DX is incredible.
But let’s be real: moving from npm run dev on your laptop to a robust, containerised production environment is where the headaches usually start.
In this guide we aren’t just going to “make it run”. We are going to build a high‑performance deployment pipeline using Docker and Bun. We’ll implement a custom asset server that caches files in RAM, handles compression, and serves your app at lightning speed.
0️⃣ The Prerequisite (Don’t Skip This!)
Before we touch a single Dockerfile, there is one crucial requirement.
According to the official TanStack Start documentation, deploying with Bun currently requires React 19. If your project is still on React 18, the server might crash or behave unpredictably.
Make sure to upgrade your dependencies first:
bun install react@19 react-dom@19
Ready? Let’s build.
1️⃣ The Foundation: Vite Configuration
First, we need to make sure Vite is ready for production. We want our path aliases (like @/components) to work correctly and to strip out console logs so our production logs stay clean.
vite.config.ts
// vite.config.ts
import { defineConfig } from "vite";
import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
esbuild: {
// Clean up logs in production
drop: ["console", "debugger"],
},
server: {
port: 3058,
},
plugins: [
devtools(),
// This plugin is vital for resolving paths like "@/lib/utils"
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
2️⃣ The Secret Sauce: A Custom Bun Server
This is the cool part. Instead of running node server.js, we’ll use a custom script that leverages Bun’s native speed. The script acts as a smart asset server: it loads small files (icons, CSS, tiny JS chunks) directly into memory (RAM), serves them with automatic Gzip compression, and generates ETags for perfect browser caching.
Why?
- Speed – RAM is faster than disk.
- Compression – Gzip is handled automatically.
- Efficiency – ETags let browsers cache assets perfectly.
Create a file named server.ts in your project root (see the full reference implementation in the TanStack Router repo) and paste the code below:
// server.ts
import path from "node:path";
// --- Configuration ---
const SERVER_PORT = Number(process.env.PORT ?? 3000);
const CLIENT_DIRECTORY = "./dist/client";
const SERVER_ENTRY_POINT = "./dist/server/server.js";
// Simple Logging Utility
const log = {
info: (msg: string) => console.log(`[INFO] ${msg}`),
error: (msg: string) => console.error(`[ERROR] ${msg}`),
success: (msg: string) => console.log(`[SUCCESS] ${msg}`),
};
// --- Asset Preloading Logic ---
// 5 MiB limit for in‑memory files
const MAX_PRELOAD_BYTES = Number(
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024
);
// (Helper functions for ETag, Gzip, and Glob patterns omitted for brevity
// but ensure you include the full logic from the source provided earlier!)
/**
* The Main Server Initializer
*/
async function initializeServer() {
log.info("Starting Production Server...");
// 1. Load the TanStack Start handler
let handler: { fetch: (request: Request) => Response | Promise };
try {
const serverModule = (await import(SERVER_ENTRY_POINT)) as any;
handler = serverModule.default;
log.success("TanStack Start handler initialized");
} catch (error) {
log.error(`Failed to load handler: ${String(error)}`);
process.exit(1);
}
// 2. Initialise static routes (scans ./dist/client)
// Note: In your full file, ensure you include the `initializeStaticRoutes` function!
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY);
// 3. Start Bun server
const server = Bun.serve({
port: SERVER_PORT,
routes: {
// Serve cached assets first
...routes,
// Fallback to the App handler for everything else
"/*": (req: Request) => {
return handler.fetch(req);
},
},
});
log.success(`Server listening on http://localhost:${String(server.port)}`);
}
initializeServer().catch((err) => {
log.error(String(err));
});
// ... (Paste the rest of the helper functions: initializeStaticRoutes, etc. here)
Important: Use the full
server.tscode from the official materials to get the complete caching benefits.
3️⃣ The Dockerfile: Small & Efficient
We’ll use a multi‑stage build to keep the final image tiny. (The rest of the Dockerfile content follows the same pattern – copy the built assets, install only production dependencies, and run the Bun server.)
# ---- Builder Stage ----
FROM oven/bun:1 AS builder
WORKDIR /app
# Install dependencies (including React 19)
COPY package.json bun.lockb ./
RUN bun install --production=false
# Copy source files
COPY . .
# Build the client and server bundles
RUN bun run build
# ---- Runtime Stage ----
FROM oven/bun:1-alpine AS runtime
WORKDIR /app
# Copy only the built output and production deps
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .
COPY --from=builder /app/bun.lockb .
# Install only production dependencies
RUN bun install --production
# Expose the port the server listens on
EXPOSE 3000
# Start the Bun server
CMD ["bun", "run", "server.ts"]
Why This Docker Setup Works
We keep our final Docker image tiny because we don’t ship the build tools (TypeScript, Vite, etc.) into the production container—only what’s needed to run the app.
Key Technical Detail
Vite needs environment variables at build time (e.g., your API URL) so it can “bake” them into the client‑side JavaScript. Pay special attention to the ARG section of the Dockerfile.
Dockerfile (Multi‑stage)
# syntax=docker/dockerfile:1
# =============================================
# Stage 1: Builder
# =============================================
FROM oven/bun:1-alpine AS builder
WORKDIR /app
# Install dependencies (cached layer)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy the source code
COPY . .
# --- IMPORTANT ---
# Vite needs these variables AT BUILD TIME to replace import.meta.env values
ARG VITE_API_URL
ARG VITE_BETTER_AUTH_URL
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BETTER_AUTH_URL=${VITE_BETTER_AUTH_URL}
# Build the app
RUN bun run build
# =============================================
# Stage 2: Runner (Production)
# =============================================
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Install curl for healthchecks
RUN apk add --no-cache curl
# Copy package files
COPY package.json bun.lock ./
# Install ONLY production deps (react, etc.)
RUN bun install --production
# Copy the build artifacts from the Builder stage
COPY --from=builder --chown=bun:bun /app/dist ./dist
COPY --from=builder --chown=bun:bun /app/server.ts ./server.ts
USER bun
# The port is dynamic via Env Vars
ENV PORT=3058
EXPOSE 3058
# Start the custom Bun server
CMD ["bun", "server.ts"]
4️⃣ Orchestration: Docker Compose
Using Docker Compose makes it easy to manage environment variables, ports, and health checks.
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
# Pass these to the Builder stage!
VITE_BETTER_AUTH_URL: ${VITE_BETTER_AUTH_URL}
VITE_API_URL: ${VITE_API_URL} # (optional, if needed at build time)
environment:
# Runtime config for the server
NODE_ENV: production
PORT: ${PORT:-3058}
# App secrets & configs
VITE_API_URL: ${VITE_API_URL}
DATABASE_URL: ${DATABASE_URL}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
# SMTP / Email config
HOST_EMAIL: ${HOST_EMAIL}
HOST_PORT: ${HOST_PORT}
HOST_AUTH_USER: ${HOST_AUTH_USER}
HOST_AUTH_PASS: ${HOST_AUTH_PASS}
ports:
# Map host port to container port
- '${PORT:-3058}:${PORT:-3058}'
restart: always
healthcheck:
# Ping the server to ensure it's alive
test: ['CMD-SHELL', 'curl -fsS http://localhost:${PORT:-3058} || exit 1']
interval: 10s
timeout: 5s
retries: 5
Conclusion
You’re no longer running a development server inside a container. This setup gives you a highly optimized, memory‑caching, Bun‑powered production environment.
Quick Recap
- React 19 – mandatory for Bun development
- server.ts – provides advanced caching and gzip compression
- Dockerfile – multi‑stage build that handles build‑time env vars correctly
- Docker Compose – orchestrates runtime with health checks
- Create a
.envfile with the required variables. - Run:
docker compose up --build -d
- Watch your TanStack Start app fly!
For more details, visit my blog.
