CoreMVP

Docker Deployment

Deploy with Docker and Docker Compose.

Overview

Docker deployment packages your Next.js app into a container for consistent deployment anywhere.

Prerequisites

  • Docker 24+
  • Production server (VPS, cloud instance)

Dockerfile

Create a Dockerfile in the project root. The shared base pins Node.js 22.23.4 for dependency installation, the build, and the running app. When updating Node.js, select a patched image that satisfies package.json and rebuild the image:

Dockerfile
FROM node:22.23.4-alpine3.24 AS base

# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps

# Build the app
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js reads these public settings during the build.
COPY .env.build .env.production

ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

CMD ["node", "server.js"]

Add output: 'standalone' to next.config.mjs before building this image.

Create .dockerignore beside the Dockerfile so server secrets and local build output stay outside the build context. Only .env.build, containing public values, is included:

.dockerignore
node_modules
.next
.git
tmp
.env*
!.env.build

Docker Compose

For easier deployment with environment variables, use Compose. The healthcheck uses Node.js's built-in fetch, so it needs no additional tools in Alpine:

docker-compose.yml
version: '3.8'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "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', { signal: AbortSignal.timeout(5000) })
            .then((response) => process.exit(response.ok ? 0 : 1))
            .catch(() => process.exit(1))
      interval: 30s
      timeout: 10s
      retries: 3

Deployment Steps

Save Public Build Settings

Use the values you've saved in Hosted Environment Variables. Put every NEXT_PUBLIC_* entry in .env.build at the project root. The example includes Stripe live billing; omit its Stripe entries for a Polar-only app. Polar's selection, offer mappings and credentials are server-only and belong in .env.runtime; follow hosted Polar setup and the Polar environment reference. Keep the shared app and Supabase public values below for either provider.

.env.build
# App origin and Supabase Auth
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...

# Stripe only: live settings shared by lifetime and subscription billing
NEXT_PUBLIC_STRIPE_MODE=live
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

# Lifetime tiers: Individual and Team
NEXT_PUBLIC_STRIPE_INDIVIDUAL_PRODUCT_ID=prod_replace_individual
NEXT_PUBLIC_STRIPE_INDIVIDUAL_PRICE_ID=price_replace_individual
NEXT_PUBLIC_STRIPE_TEAM_PRODUCT_ID=prod_replace_team
NEXT_PUBLIC_STRIPE_TEAM_PRICE_ID=price_replace_team

# Subscription tiers: Starter, Pro, and Business (monthly/yearly)
NEXT_PUBLIC_STRIPE_STARTER_PRODUCT_ID=prod_replace_starter
NEXT_PUBLIC_STRIPE_STARTER_PRICE_ID_MONTHLY=price_replace_starter_monthly
NEXT_PUBLIC_STRIPE_STARTER_PRICE_ID_YEARLY=price_replace_starter_yearly
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID=prod_replace_pro
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID_MONTHLY=price_replace_pro_monthly
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID_YEARLY=price_replace_pro_yearly
NEXT_PUBLIC_STRIPE_BUSINESS_PRODUCT_ID=prod_replace_business
NEXT_PUBLIC_STRIPE_BUSINESS_PRICE_ID_MONTHLY=price_replace_business_monthly
NEXT_PUBLIC_STRIPE_BUSINESS_PRICE_ID_YEARLY=price_replace_business_yearly

For Stripe live billing, configure only the offers and cadences you sell. Missing Price IDs leave those choices unavailable. Shared pricing is presentation only; the server validates supplied mappings when the corresponding offer is used.

For Stripe hosted test payments, use NEXT_PUBLIC_STRIPE_MODE=test, a pk_test_... key, and your matching test catalog instead. The hosting target does not select Stripe's mode. Next.js compiles public values into the bundle, so rebuild the image whenever .env.build changes.

Save Server Runtime Settings

Put the remaining unprefixed settings from the hosted reference in .env.runtime. Use credentials from the same Supabase project as .env.build. The Stripe entries below apply only when using Stripe and must match its public mode. For Polar, omit those entries and save the server-only Polar settings linked above, using sandbox for the first hosted check. Keep retained Stripe settings if Stripe still handles existing billing or outstanding deliveries.

.env.runtime
# Supabase database and server access
SUPABASE_SECRET_KEY=sb_secret_...
DATABASE_URL=postgresql://postgres:[password]@db.xxx.supabase.co:5432/postgres

# Stripe only: server access and this deployment's webhook
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

For Stripe test payments, use sk_test_... and the test webhook's signing secret. For Polar, configure this origin's hosted webhook, not a local listener's secret. Configure real application email for its manual hosted checks. Keep server secrets out of .env.build. The template ignores .env* files in Git, and .dockerignore excludes .env.runtime from the image build. Compose loads these files into the running container.

Restrict access to the runtime file:

chmod 600 .env.runtime

Build and Run

docker compose up -d --build

This builds the image and starts the container.

Verify Deployment

docker compose ps
curl http://localhost:3000/api/health

Nginx Reverse Proxy

Put Nginx in front for SSL and caching:

/etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable the site:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

SSL with Let's Encrypt

sudo certbot --nginx -d yourdomain.com

Updating the App

Pull latest code and rebuild:

git pull
docker compose up -d --build

For zero-downtime updates, use rolling deployments or container orchestration.

Troubleshooting

Container won't start

Check logs:

docker compose logs web

Port already in use

sudo lsof -i :3000

Environment variables not loaded

Validate the Compose configuration without printing expanded secret values:

docker compose config --quiet

Confirm both environment files exist at the project root. If a public mode, key, or catalog ID changed, rebuild the image; restarting the container keeps the public values from its previous build.

Was this page helpful?

On this page