Self-Hosted Next.js: Docker, Environment, Auth Setup
Self-hosting Next.js on your own servers gives you full control over infrastructure, secrets, and data. Unlike Vercel (which manages deployment), self-hosted requires managing Docker containers, environment variables, databases, and reverse proxies. This approach is more complex but necessary if you have regulatory requirements (data residency), need custom infrastructure, or want to avoid vendor lock-in. This guide covers building a production-ready Docker setup with authentication.
Building a Next.js Docker Image
Create a Dockerfile that builds and runs your Next.js app:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the Next.js app
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built app from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
# Expose port
EXPOSE 3000
# Start the app
CMD ["npm", "start"]
This uses a multi-stage build: the builder stage compiles TypeScript and bundles dependencies; the production stage copies only the compiled app, reducing the image size from ~1GB to ~500MB.
Build the image:
docker build -t myapp:latest .
# Test locally
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://... \
-e NEXTAUTH_SECRET=... \
myapp:latest
Docker Compose for Local Development
For development with a database and cache, use Docker Compose:
# docker-compose.yml
version: '3.9'
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: auth_db
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgresql://postgres:password@db:5432/auth_db
REDIS_URL: redis://redis:6379
SESSION_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: dev-secret
depends_on:
- db
- redis
volumes:
- .:/app
- /app/node_modules
volumes:
postgres_data:
Start the full stack:
docker-compose up -d
# Check status
docker-compose ps
# View logs
docker-compose logs app
# Stop
docker-compose down
Managing Secrets in Production
Never hardcode secrets in Dockerfile or docker-compose.yml. Instead, use environment variables or secret files:
Option 1: Environment Variables via .env file
# .env.production (commit to git, but only with non-secret values)
NODE_ENV=production
NEXTAUTH_URL=https://yourdomain.com
# secrets.env (DO NOT COMMIT, add to .gitignore)
DATABASE_URL=postgresql://prod_user:[email protected]:5432/auth_db
REDIS_URL=redis://redis.example.com:6379
SESSION_ENCRYPTION_KEY=production-encryption-key
NEXTAUTH_SECRET=production-secret
Load secrets at runtime:
docker run --env-file secrets.env myapp:latest
Option 2: Docker Secrets (for Swarm deployments)
# Create secrets
echo "secret-value" | docker secret create SESSION_ENCRYPTION_KEY -
# Reference in docker-compose.yml
services:
app:
secrets:
- SESSION_ENCRYPTION_KEY
environment:
SESSION_ENCRYPTION_KEY_FILE: /run/secrets/SESSION_ENCRYPTION_KEY
Option 3: Environment Variable Manager (recommended)
Use a tool like dotenv-vault to encrypt and manage secrets:
# Install
npm install dotenv-vault
# Create encrypted vault
npx dotenv-vault new
# Encrypt production secrets
npx dotenv-vault set PRODUCTION SESSION_ENCRYPTION_KEY secret-value
# Push to your repository (encrypted)
git add .env.vault
git commit -m "Update encrypted secrets"
Database Migrations in Docker
Run database migrations as part of deployment:
// scripts/migrate.ts
import { Pool } from 'pg';
import fs from 'fs';
import path from 'path';
async function runMigrations() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const migrationsDir = path.join(__dirname, '../migrations');
const files = fs.readdirSync(migrationsDir).sort();
for (const file of files) {
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf-8');
console.log(`Running migration: ${file}`);
await pool.query(sql);
}
await pool.end();
console.log('Migrations completed');
}
runMigrations().catch(console.error);
In your Dockerfile, run migrations on startup:
# Dockerfile (updated)
...
CMD ["sh", "-c", "npm run migrate && npm start"]
Or use a separate migration container in docker-compose.yml:
services:
migrate:
build: .
environment:
DATABASE_URL: postgresql://...
depends_on:
- db
command: npm run migrate
restart: "no"
app:
build: .
depends_on:
- migrate
Reverse Proxy Configuration (Nginx)
In production, use a reverse proxy (Nginx or Caddy) to handle HTTPS, load balancing, and security headers:
# nginx.conf
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name yourdomain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://nextjs;
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_http_version 1.1;
proxy_set_header Connection "upgrade";
proxy_set_header Upgrade $http_upgrade;
}
}
Use Let's Encrypt for free SSL certificates:
# Install Certbot
apt-get install certbot python3-certbot-nginx
# Generate certificate
certbot certonly --standalone -d yourdomain.com
# Auto-renew
certbot renew --dry-run
Docker Compose for Production
Combine app, database, cache, and reverse proxy:
# docker-compose.prod.yml
version: '3.9'
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: always
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
restart: always
app:
image: myapp:latest
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@db:5432/auth_db
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: https://yourdomain.com
env_file:
- secrets.env
depends_on:
- db
- redis
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 10s
timeout: 5s
retries: 3
nginx:
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt
depends_on:
- app
restart: always
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
postgres_data:
redis_data:
Deploy with:
docker-compose -f docker-compose.prod.yml up -d
Key Takeaways
- Use multi-stage Docker builds to create small, efficient images (~500MB for Next.js).
- Never hardcode secrets; use environment variables and secret managers.
- Run database migrations as part of deployment to ensure schema consistency.
- Use a reverse proxy (Nginx) for HTTPS, security headers, and load balancing.
- Health checks ensure the container restarts if the app crashes.
- Store persistent data (database, Redis) in named volumes to survive container restarts.
Frequently Asked Questions
How do I scale to multiple app instances?
Use a load balancer (Nginx, HAProxy, or cloud load balancer) to distribute traffic. Update the upstream block in nginx.conf to include multiple app instances. Use sticky sessions for authentication if needed (set proxy_set_header X-Forwarded-For).
How do I monitor a self-hosted Next.js app?
Use tools like Prometheus (metrics) and Grafana (visualization). Add health endpoints (/api/health) and expose metrics from your app. Use Sentry for error tracking and log aggregation tools (ELK, Loki) for centralized logging.
Can I use Kubernetes instead of Docker Compose?
Yes. Kubernetes is more complex but better for scaling. Create a Deployment for the app, StatefulSet for the database, and Service for networking. Use ConfigMaps for non-secret config and Secrets for sensitive data.
How do I roll back to a previous version?
Tag images by version: docker build -t myapp:1.0.0 . and docker build -t myapp:1.0.1 .. In docker-compose.yml, specify the image tag. To rollback, change the tag to a previous version and restart the container.