Environment Setup for Node.js Deployments¶
Your laptop is not production
Running node app.js with NODE_ENV=development from your terminal works fine until 10,000 users show up and the app crashes because you forgot to set file descriptors or the process manager isn't handling restarts Dev/staging/prod environments must diverge early or you'll debug "works on my machine" at 3AM
Development vs Staging vs Production¶
Three environments , three distinct configurations , zero shortcuts
Development - your local machine , hot reload , debug logs , relaxed CORS
NODE_ENV=development
LOG_LEVEL=debug
CORS_ORIGIN=http://localhost:3000
Staging - mirrors production but with fake data , full monitoring , staging DB
NODE_ENV=production
LOG_LEVEL=info
DATABASE_URL=postgres://staging_user:pass@staging-db:5432/myapp
Production - locked down , minimal logs , real data , no debug endpoints
NODE_ENV=production
LOG_LEVEL=warn
DATABASE_URL=postgres://prod_user:strongpass@prod-db:5432/myapp
NODE_OPTIONS="--max-old-space-size=2048"
The NODE_ENV flag changes Express behavior drastically - it skips view caching in dev, enables stack traces on errors, and loads debug middleware In production it compresses , caches templates , and hides stack traces Set it wrong and either your perf tanks or your internals leak
NODE_ENV - What It Actually Does¶
This string does more than you think
// NODE_ENV=development - Express shows full stack traces
// GET /nonexistent -> HTML response with file paths , line numbers
Error: Not Found
at /app/routes/index.js:42:15
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
// NODE_ENV=production - Express returns minimal error
// GET /nonexistent -> {"message": "Not Found"}
Express optimizations enabled by NODE_ENV=production: - View template caching - compiled templates stored in memory , no disk reads - CSS/extended CSS caching - precompiled templates cached for each render - Error stack traces disabled - attackers can't read your code structure - X-Powered-By: Express header added (disable it separately with app.disable('x-powered-by'))
Other libraries check the same flag: - React - dev builds include warnings and prop-type checks - Morgan - skip logging in test environment - Bcrypt - fewer rounds in dev (faster but weaker)
// Good practice - use env-aware config
const config = {
development: {
logLevel: 'debug',
bcryptRounds: 8,
cacheViews: false,
prettyPrint: true
},
production: {
logLevel: 'warn',
bcryptRounds: 12,
cacheViews: true,
prettyPrint: false
}
}[process.env.NODE_ENV || 'development']
Process Managers - PM2¶
Running node app.js directly is for tutorials and your laptop
Production needs a process manager that handles crashes , logs , and resource limits
PM2 key features: - Fork mode - one process per CPU core - Cluster mode - built-in load balancer across CPUs - Auto-restart on crash - zero downtime - Watch mode - restart when files change (dev only) - Log management - stdout/stderr capture , rotation - Startup script - app starts on system boot
# Install PM2
npm install -g pm2
# Start in cluster mode - 4 processes
pm2 start app.js -i 4 --name myapp
# Restart gracefully (zero-downtime reload)
pm2 reload myapp
# Save process list for reboot
pm2 save
pm2 startup
# ecosystem.config.js - structured config
module.exports = {
apps: [{
name: 'myapp',
script: 'dist/index.js',
instances: process.env.NODE_ENV === 'production' ? 'max' : 1,
exec_mode: 'cluster',
env: {
NODE_ENV: 'development',
PORT: 3000
},
env_production: {
NODE_ENV: 'production',
PORT: 8080,
NODE_OPTIONS: '--max-old-space-size=1024'
},
max_memory_restart: '512M',
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
}]
}
Docker for Node.js¶
Containers eliminate the "works on my machine" gap
Multi-stage builds keep images small - smaller images mean faster deploys and fewer attack vectors
# Dockerfile - multi-stage production build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Production stage - minimal runtime
FROM node:22-alpine AS production
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup --from=builder /app/dist ./dist
COPY --chown=appuser:appgroup --from=builder /app/node_modules ./node_modules
COPY --chown=appuser:appgroup --from=builder /app/package.json ./
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/healthz || exit 1
CMD ["node", "dist/index.js"]
.dockerignore - don't ship your node_modules and .env to the registry
node_modules
npm-debug.log
.env
.git
.gitignore
*.md
test
coverage
.dockerignore
Dockerfile
Running as root in containers is a showstopper - if an attacker breaks out of your app process they have root inside the container Use the non-root user pattern shown above (the USER appuser line) or prepare for containment breaches
# Build and run
docker build -t myapp:latest .
docker run -d \
-p 8080:8080 \
-e NODE_ENV=production \
-e DATABASE_URL=postgres://user:pass@db:5432/myapp \
--restart unless-stopped \
--name myapp-prod \
myapp:latest
Environment Variables Per Environment¶
One .env to rule them all is how secrets leak to GitHub
Use environment-specific files and never commit actual secrets
# .env.development - committed to repo , safe defaults
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
PORT=3000
# .env.production - NEVER committed , set in CI/CD or secret manager
DATABASE_URL=postgres://prod_user:${DB_PASSWORD}@prod-db:5432/myapp
REDIS_URL=redis://${REDIS_AUTH}@redis-cluster:6379
JWT_SECRET=${JWT_SECRET}
LOG_LEVEL=warn
PORT=8080
# .gitignore - keep production secrets out of version control
.env.production
.env.local
*.pem
# Loading them properly - use dotenv with path
if (process.env.NODE_ENV === 'test') {
require('dotenv').config({ path: '.env.test' })
} else if (process.env.NODE_ENV === 'production') {
// Production secrets from env - not from .env files
} else {
require('dotenv').config({ path: '.env.development' })
}
In CI/CD environments (GitHub Actions , GitLab CI , Jenkins) , inject secrets through environment variables or secret stores Never write secrets to .env files in build scripts - anyone with CI log access can read them
Security Checklist for Environment Setup¶
Every item here has been ignored by someone who got breached
- Run as non-root - containers and servers should use a dedicated user with minimum permissions The PM2 and Docker setups above show this
- NODE_ENV=production - enables Express optimizations , disables debug endpoints , hides stack traces Verify it in your start script:
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { console.warn('WARNING: running in non-production mode') } - Don't expose internal ports - only bind the application port (8080) , not debug ports (9229)
- Limit file descriptors - Node.js apps hold connections open and default ulimits are too low for production
ulimit -n 65536in your startup script - Read-only filesystem - in Docker , mount the app directory read-only to prevent file modification attacks
- Environment variable validation - use Zod or Joi to validate env vars at startup so misconfiguration crashes early instead of silently failing
// env-validation.js - fail fast on bad config
const zod = require('zod')
const envSchema = zod.object({
NODE_ENV: zod.enum(['development', 'production', 'test']),
PORT: zod.string().default('8080'),
DATABASE_URL: zod.string().url(),
JWT_SECRET: zod.string().min(32),
LOG_LEVEL: zod.enum(['debug', 'info', 'warn', 'error']).default('info'),
REDIS_URL: zod.string().optional()
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.format())
process.exit(1)
}
module.exports = parsed.data
Prerequisites¶
- db_06_migrations.md - understand schema management before deploying
next -> deploy_02_pm2.md