CoreMVP

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

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:

.dockerignore
node_modules
.output
.tanstack
.source
.vercel
.cache
.git
tmp
e2e/node_modules
.env*
!.env.build

Compose configuration

compose.yml
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: 3

The 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.runtime

Compose supplies these values at runtime. .dockerignore keeps the runtime file outside the image build context.

Build and run

docker compose up -d --build

Verify the container and the embedded API:

docker compose ps
curl --fail http://127.0.0.1:3000/api/health

Then 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 --build

This 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 --quiet

The 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

Was this page helpful?

On this page