Docker Deployment
Build the Nitro Node application into a non-root container with separate public build values and server runtime secrets.
See the hosted deployment status before using this deployment guide.
Create these three files at the repository root to run the same Nitro Node output in a container. Supabase and your selected payment provider remain external services. This is an advanced alternative to Vercel; you own the container, networking, TLS, updates, and recovery.
Dockerfile
FROM node:24.22.1-bookworm-slim AS base
FROM base AS builder
COPY --from=oven/bun:1.4.3 /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
# This file contains public NEXT_PUBLIC_* values only.
COPY .env.build .env.production
RUN bun run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]The builder uses Bun for the frozen install and build command. Vite's Node entry runs with Node 24. The final image contains the built output and Node; it does not need the source tree, Bun, or a second production install.
Exclude server secrets, provider link metadata, caches, and local build output from the context. Keep only the public build file:
node_modules
.output
.tanstack
.source
.vercel
.cache
.git
tmp
e2e/node_modules
.env*
!.env.buildCompose configuration
services:
web:
build: .
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env.build
- .env.runtime
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3The health check uses the Node binary already in the image. The loopback port binding is intended for a reverse proxy on the same host.
Deployment steps
Save public build settings
Copy every NEXT_PUBLIC_* entry from the
shared hosted environment example
into ignored .env.build. Set the app's canonical HTTPS origin, the hosted
Supabase URL and publishable key. For Stripe checkout, also set the matching
Stripe mode, publishable key, and enabled catalog IDs; use test mode for initial
checks. For Polar checkout, follow Polar Billing for
the sandbox offer mappings.
Keep all unprefixed server credentials out of this file. Vite embeds the public
values during the build; changing .env.build requires rebuilding the image.
A container restart cannot replace the browser's compiled settings.
Save server runtime settings
Put the unprefixed settings from the same hosted reference in ignored
.env.runtime. Use the same Supabase project as the public build settings;
when using Stripe, match its account and mode too. The runtime needs DATABASE_URL and
SUPABASE_SECRET_KEY. For Stripe, add STRIPE_SECRET_KEY and this
endpoint's STRIPE_WEBHOOK_SECRET. For Polar, set PAYMENT_PROVIDER=polar
and the server-side Polar settings described in Polar Billing.
Keep provider credentials and signing secrets in the runtime environment.
Choose EMAIL_DELIVERY_MODE=real with RESEND_API_KEY and
RESEND_FROM_EMAIL for actual application email. For protected test capture,
use EMAIL_DELIVERY_MODE=test_capture with TEST_EMAIL_CAPTURE_TOKEN as
explained in Email Testing. When a send path validates
delivery, this production Node runtime rejects an unset mode or mock. A
successful build or liveness check alone does not validate email delivery.
Restrict the file's permissions:
chmod 600 .env.runtimeCompose supplies these values at runtime. .dockerignore keeps the runtime
file outside the image build context.
Build and run
docker compose up -d --buildVerify the container and the embedded API:
docker compose ps
curl --fail http://127.0.0.1:3000/api/healthThen open /, /pricing, a Docs page, and a direct deep link. Liveness alone
does not prove Auth, billing, provider callbacks, or persisted access.
Reverse proxy and HTTPS
Terminate HTTPS at your proxy and forward to 127.0.0.1:3000. Preserve Host,
X-Forwarded-Host, and X-Forwarded-Proto so the application sees the original
public host and scheme. Keep the container port private; accept forwarded
headers only through the proxy you control. Use the example in
VPS Deployment.
Set NEXT_PUBLIC_APP_URL and Supabase's Site URL and Redirect URLs to the
same HTTPS origin. Configure the selected provider's webhook destination for
that origin: /api/webhooks/stripe for Stripe or /api/webhooks/polar for
Polar. Keep any previous provider endpoint and credentials needed by retained
billing or outstanding deliveries. Verify sign-in, refresh cookies,
and the test-mode billing journeys through that origin before launch. Hosted
Polar on TanStack Start has not been verified; complete the local sandbox
journey and stop before live Polar activation until the hosted path is verified.
A local container check does not prove public DNS or TLS.
Update and inspect
Pull the reviewed source and rebuild:
git pull
docker compose up -d --buildThis may interrupt requests. Keep the prior accepted image and configuration available for rollback; this recipe does not provide a rolling deployment.
For a failed start, read the service log. Validate Compose without printing expanded secret values:
docker compose logs web
docker compose config --quietThe Node reference is 24.22.1 or later on 24.x; 22.23.4 or later on 22.x is also supported. Rebuild after public settings or the Node image change.
What's next
- VPS Deployment — configure the host and reverse proxy.
- Environment Variables — check build and runtime inputs.
- Payments — verify signed events and saved access.
Was this page helpful?