Security Hardening untuk Next.js di Docker — Panduan Lengkap untuk Developer Indonesia
Update 2026: Artikel ini focus pada hardening aplikasi Next.js yang sudah di-deploy dengan Docker + Caddy. Asumsi: kamu sudah pernah deploy aplikasi ke production.
Pendahuluan
Hai developer! Di artikel-artikel sebelumnya, kita udah bahas cara deploy, migrasi database, dan monitoring. Tapi ada satu hal yang sering terlewat: security.
Pernah nggak sih kamu cek curl ke aplikasi dan tiba-tiba bisa akses file sensitif? Atau aplikasi kamu jadi lambat karena diserang bot? Atau worse — ada yang inject malicious code lewat form input?
Di artikel ini, kita bakal bahas cara hardening Next.js di Docker dari berbagai sisi: 1. HTTPS & TLS — Encryption, TLS versions, certificates 2. Security Headers — CSP, HSTS, X-Frame-Options, dll 3. Rate Limiting — Protection dari brute force dan DDoS 4. CORS Configuration — Cross-origin request yang aman 5. Input Validation & Sanitization — Prevent injection attacks 6. Environment Variables Security — Jangan expose secrets 7. Docker Security — Container hardening 8. Brute Force Protection — Login protection 9. Security Checklist — Ringkasan semua yang harus dicek
1. HTTPS & TLS Configuration
Caddy TLS Settings
Di artikel sebelumnya, kita udah pakai Caddy untuk auto-HTTPS. Tapi ada beberapa setting yang perlu di-hardening:
{
# Global settings
email admin@domainkamu.com
acme_ca https://acme-v02.api.letsencrypt.org/directory
key_type rsa4096
# Default ports (opsional, sudah default Caddy)
http_port 80
https_port 443
}
domainkamu.com {
encode gzip zstd
# Strict TLS — reject koneksi yang lemah
tls {
protocols tls1.3 tls1.3
ciphers TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256 TLS_AES_128_GCM_SHA256
}
reverse_proxy localhost:3000
}
Force HTTPS
Pastikan semua HTTP request di-redirect ke HTTPS:
:80 {
@http {
not header X-Forwarded-Proto https
}
redir @http https://{host}{uri} 301
}
HSTS (HTTP Strict Transport Security)
Tambahkan header HSTS agar browser selalu akses via HTTPS:
domainkamu.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
}
Warning: Pastikan semua subdomain sudah mendukung HTTPS sebelum enable
includeSubDomains. Salah-config bisa bikin subdomain nggak accessible.
2. Security Headers
Complete Security Headers
Update Caddyfile dengan semua security headers yang penting:
domainkamu.com {
encode gzip zstd
header {
# HSTS — force HTTPS
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Prevent MIME type sniffing
X-Content-Type-Options "nosniff"
# Prevent clickjacking
X-Frame-Options "DENY"
# XSS protection (legacy tapi masih useful)
X-XSS-Protection "1; mode=block"
# Referrer policy — kontrol info yang dikirim saat navigasi
Referrer-Policy "strict-origin-when-cross-origin"
# Permissions policy — matikan fitur yang nggak perlu
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
# Content Security Policy (CSP) — lihat detail inline di bawah
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://images.unsplash.com https://picsum.photos; font-src 'self' data:; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none';"
# Remove server identification
-Server
-X-Powered-By
}
reverse_proxy localhost:3000
}
Content Security Policy (CSP) Explained
CSP adalah header paling powerful untuk prevent XSS. Penjelasan singkat:
Directive | Fungsi | Contoh |
|---|---|---|
| Default policy untuk semua resource |
|
| Sumber JavaScript yang boleh dijalankan |
|
| Sumber CSS |
|
| Sumber gambar |
|
| Sumber untuk fetch/XHR/WebSocket |
|
| Siapa yang boleh embed iframe |
|
| Sumber plugin (Flash, Java) |
|
Strict CSP untuk Production
Kalau aplikasi kamu nggak butuh inline scripts, gunakan CSP yang lebih strict:
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.domainkamu.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self';"
Note: Strict CSP akan block semua inline scripts. Kalau pakai Next.js dengan styled-jsx, kamu perlu adjust
script-srcuntuk allow 'unsafe-inline' atau use nonces.
3. Rate Limiting
Protection dari brute force, DDoS, dan abusive usage.
Caddy Rate Limiting (butuh plugin caddy-ratelimit)
# Caddy tidak punya rate limiting bawaan — build ulang binary dengan:
# xcaddy build --with github.com/mholt/caddy-ratelimit
domainkamu.com {
encode gzip zstd
rate_limit {
zone dynamic {
key {remote_host}
events 100
window 1s
}
}
header {
# headers...
}
reverse_proxy localhost:3000
}
More Granular Rate Limiting dengan Middleware
Untuk kontrol lebih advanced, buat middleware di Next.js:
// lib/rateLimit.ts
import { NextResponse } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
prefix: 'ratelimit',
})
export async function rateLimitMiddleware(request: Request) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? '127.0.0.1'
const { success, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return new NextResponse(
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
{
status: 429,
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
}
}
)
}
return null // Allow request
}
Login Rate Limiting (Brute Force Protection)
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
import { ratelimit } from '@/lib/rateLimit'
import { login } from '@/lib/auth'
export async function POST(request: Request) {
// Rate limit check
const rateLimitResult = await rateLimitMiddleware(request)
if (rateLimitResult) return rateLimitResult
try {
const { email, password } = await request.json()
// Login logic
const result = await login(email, password)
if (!result.success) {
// Log failed attempt untuk monitoring
console.log(`Failed login attempt for: ${email}`)
return NextResponse.json({ message: 'Email atau password tidak valid.' }, { status: 401 })
}
return NextResponse.json({ success: true, user: result.user })
} catch (error) {
console.error('Login error:', error)
return NextResponse.json({ message: 'Internal server error' }, { status: 500 })
}
}
4. CORS Configuration
Caddy CORS Headers
domainkamu.com {
encode gzip zstd
# CORS configuration
@cors-preflight {
method OPTIONS
header Origin *
}
handle @cors-preflight {
header {
Access-Control-Allow-Origin "https://domainkamu.com"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Access-Control-Allow-Credentials "true"
Access-Control-Max-Age "86400"
}
respond "" 204
}
header {
# Existing headers...
# CORS for actual requests
Access-Control-Allow-Origin "https://domainkamu.com"
Access-Control-Allow-Credentials "true"
}
reverse_proxy localhost:3000
}
Next.js API CORS (Alternative)
Kalau lebih suka handle CORS di Next.js:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const allowedOrigins = [
'https://domainkamu.com',
'https://www.domainkamu.com',
process.env.NEXT_PUBLIC_SITE_URL,
].filter(Boolean)
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin')
// Handle preflight
if (request.method === 'OPTIONS') {
if (origin && allowedOrigins.includes(origin)) {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
})
}
return new NextResponse(null, { status: 204 })
}
// Add CORS headers to response
const response = NextResponse.next()
if (origin && allowedOrigins.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.set('Access-Control-Allow-Credentials', 'true')
}
return response
}
export const config = {
matcher: '/api/:path*',
}
5. Input Validation & Sanitization
Zod Schema Validation
Gunakan Zod untuk validasi input di API routes:
// lib/validations.ts
import { z } from 'zod'
export const loginSchema = z.object({
email: z
.string()
.email('Email tidak valid')
.min(1, 'Email wajib diisi')
.max(255, 'Email terlalu panjang'),
password: z
.string()
.min(6, 'Password minimal 6 karakter')
.max(128, 'Password terlalu panjang'),
})
export const registerSchema = z.object({
name: z
.string()
.min(2, 'Nama minimal 2 karakter')
.max(100, 'Nama terlalu panjang')
.regex(/^[a-zA-Z\s]+$/, 'Nama hanya boleh huruf dan spasi'),
email: z
.string()
.email('Email tidak valid')
.toLowerCase(),
password: z
.string()
.min(8, 'Password minimal 8 karakter')
.regex(/[A-Z]/, 'Password harus mengandung huruf besar')
.regex(/[a-z]/, 'Password harus mengandung huruf kecil')
.regex(/[0-9]/, 'Password harus mengandung angka')
.regex(/[^A-Za-z0-9]/, 'Password harus mengandung karakter khusus'),
})
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
Use Validation di API Route
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
import { loginSchema } from '@/lib/validations'
import { login } from '@/lib/auth'
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate input
const validation = loginSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
message: 'Validasi gagal',
errors: validation.error.flatten().fieldErrors
},
{ status: 400 }
)
}
const { email, password } = validation.data
const result = await login(email, password)
if (!result.success) {
return NextResponse.json(
{ message: 'Email atau password tidak valid.' },
{ status: 401 }
)
}
return NextResponse.json({ success: true, user: result.user })
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
)
}
}
Sanitize HTML Output
Untuk mencegah XSS di output:
// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'
export function sanitizeHTML(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'],
})
}
// Usage di komponen
const safeHTML = sanitizeHTML(userInput)
6. Environment Variables Security
Best Practices
Jangan commit .env ke git
# .gitignore
.env
.env.*
!.env.example
Gunakan .env.example sebagai template
# .env.example — Template untuk environment variables
# Copy ke .env dan isi nilainya
DATABASE_URL="postgresql://..."
JWT_SECRET="generate-dengan-openssl-rand-base64-32"
NEXTAUTH_SECRET="generate-dengan-openssl-rand-base64-32"
NEXTAUTH_URL="https://domainkamu.com"
Generate secrets yang strong
# Generate random string untuk JWT secret
openssl rand -base64 32
# Atau gunakan uuid
uuidgen
Gunakan secret manager di production
# Contoh: Gunakan Doppler atau HashiCorp Vault
# Doppler
doppler run -- npm start
# Atau inject via Docker
docker run -e JWT_SECRET=$(doppler secrets get JWT_SECRET --plain) ...
Docker Secrets (Docker Swarm)
# docker-compose.prod.yml
version: "3.8"
services:
next-app:
image: next-app:latest
secrets:
- jwt_secret
- db_password
environment:
- JWT_SECRET=/run/secrets/jwt_secret
- DATABASE_URL=postgresql://user:${db_password}@postgres:5432/db
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txt
db_password:
file: ./secrets/db_password.txt
7. Docker Container Security
Dockerfile Hardening
# Multi-stage build untuk minimize attack surface
FROM node:18-alpine AS builder
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
# Build
RUN npm run build
# Production stage dengan security hardening
FROM node:18-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built files
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./
COPY --from=builder --chown=nextjs:nodejs /app/public ./
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
Security Options di docker-compose
# docker-compose.prod.yml
version: "3.8"
services:
next-app:
build:
context: .
dockerfile: Dockerfile.prod
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
networks:
- web
# Non-root user untuk filesystem protection
caddy:
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
Scan Images untuk Vulnerabilities
# Install Trivy
brew install trivy # macOS
# atau
apt install trivy # Ubuntu/Debian
# Scan image
trivy image node:18-alpine
# Scan di CI/CD
trivy image --exit-code 1 --severity HIGH,CRITICAL your-image:tag
8. Brute Force Protection
Enhanced Login dengan Account Lockout
// lib/auth.ts
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION = 15 * 60 * 1000 // 15 minutes
export async function login(email: string, password: string) {
// Get user dengan login attempts
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
})
if (!user) {
// Jangan reveal apakah email exist
return { success: false, message: 'Email atau password tidak valid.' }
}
// Check jika account locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000)
return {
success: false,
message: `Account terkunci. Coba lagi dalam ${minutesLeft} menit.`
}
}
// Verify password
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) {
// Increment failed attempts
const newAttempts = (user.failedLoginAttempts || 0) + 1
const updates: any = { failedLoginAttempts: newAttempts }
if (newAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION)
}
await prisma.user.update({
where: { id: user.id },
data: updates,
})
return {
success: false,
message: 'Email atau password tidak valid.'
}
}
// Reset failed attempts on successful login
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: new Date(),
},
})
return { success: true, user: { id: user.id, email: user.email, name: user.name } }
}
Prisma Schema Update
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
passwordHash String
role String @default("USER")
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
9. Security Checklist
Ini checklist yang harus kamu cek sebelum aplikasi masuk production:
✅ HTTPS & TLS
[ ] TLS 1.3 only enabled
[ ] Weak ciphers disabled
[ ] HTTPS forced (no HTTP fallback)
[ ] HSTS header configured
[ ] Certificate auto-renewal enabled
✅ Security Headers
[ ] X-Content-Type-Options: nosniff
[ ] X-Frame-Options: DENY
[ ] X-XSS-Protection: 1; mode=block
[ ] Referrer-Policy: strict-origin-when-cross-origin
[ ] Content-Security-Policy configured
[ ] Server header removed
[ ] -X-Powered-By removed
✅ Rate Limiting
[ ] Global rate limit configured (Caddy atau middleware)
[ ] Login endpoint punya rate limit khusus
[ ] API endpoints punya rate limit
[ ] Appropriate retry-after header
✅ CORS
[ ] Allowed origins dikonfigurasi dengan strict
[ ] Credentials allowed hanya dari trusted origins
[ ] Preflight request handled
✅ Input Validation
[ ] All API inputs validated dengan Zod
[ ] Password strength requirements enforced
[ ] Email format validated
[ ] SQL injection prevention (Prisma handles this, but validate anyway)
[ ] XSS prevention (sanitize HTML output)
✅ Authentication & Authorization
[ ] Passwords hashed dengan bcrypt (min 12 rounds)
[ ] JWT secret strong dan random
[ ] JWT expiration configured (15-60 minutes)
[ ] Refresh token rotation enabled
[ ] Account lockout configured
[ ] Session invalidation on logout
✅ Environment Variables
[ ] No secrets di .env files yang di-commit
[ ] Strong random secrets generated
[ ] Production secrets di secret manager
[ ] .gitignore configured
✅ Docker Security
[ ] Non-root user configured
[ ] Read-only filesystem where possible
[ ] No new privileges enabled
[ ] Capabilities dropped
[ ] Images scanned for vulnerabilities
[ ] Latest security patches applied
✅ Monitoring & Logging
[ ] Failed login attempts logged
[ ] Suspicious activity monitoring
[ ] Security alerts configured
[ ] Log rotation configured
10. Troubleshooting Common Security Issues
Error: CORS not working
# Pastikan origin ada di allowed list
# Cek header yang dikirim
curl -I -X OPTIONS https://domainkamu.com/api/test \
-H "Origin: https://domainkamu.com" \
-H "Access-Control-Request-Method: POST"
Error: Rate limit too aggressive
# Increase limit di konfigurasi caddy-ratelimit
rate_limit {
zone dynamic {
key {remote_host}
events 1000
window 1s
}
}
Error: HSTS blocking subdomain
# Remove includeSubDomains jika subdomain belum ready
Strict-Transport-Security "max-age=31536000; preload"
Error: CSP blocking legitimate scripts
# Check console di browser untuk CSP violations
# CSP reports bisa dikirim ke endpoint sendiri
Content-Security-Policy "default-src 'self'; ...; report-uri /api/csp-report"
Error: Login locked out
# Reset manual di database
docker-compose exec next-app npx prisma db execute --stdin <<< "UPDATE \"User\" SET \"failedLoginAttempts\" = 0, \"lockedUntil\" = NULL WHERE email = 'admin@domain.com';"
11. Kesimpulan
Nah itu dia panduan security hardening untuk Next.js di Docker. Simpel kan?
Kesimpulan utama: 1. HTTPS + TLS 1.3 — Minimum requirement untuk production 2. Security headers — Layer defense against XSS, clickjacking, dll 3. Rate limiting — Protection dari brute force dan DDoS 4. CORS strict — Kontrol siapa bisa akses API kamu 5. Input validation — Jangan pernah trust user input 6. Docker hardening — Non-root user, read-only filesystem, capability dropping 7. Brute force protection — Account lockout untuk failed attempts
Best practices: - Always use HTTPS di production - Enable HSTS dengan preload - Use strict CSP - Implement rate limiting di semua endpoints - Validate all input dengan Zod - Use non-root user di Docker - Scan images secara regular - Monitor untuk suspicious activity - Enable logging untuk security events
Tools yang berguna: - Security Headers — Cek security headers kamu - SSL Labs — Test TLS configuration - Trivy — Container vulnerability scanner - Zod — Schema validation - DOMPurify — HTML sanitization
Semoga artikel ini membantu kamu deploy aplikasi yang lebih secure!
FAQ
Q: Apakah saya perlu semua security headers? A: Minimal: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy. Headers lain adalah defense in depth.
Q: Rate limiting bikin user frustrated, bagaimana balance? A: Start dengan limit yang generous (100 req/min), turunkan jika needed. Untuk login, lebih strict (5 attempts per 15 menit).
Q: CSP terlalu complicated untuk setup manual? A: Mulai dengan policy yang longgar, lalu tighten secara bertahap. Gunakan browser DevTools untuk identify violations.
Q: Docker security overkill untuk project kecil? A: Even small projects butuh basic security. Non-root user dan read-only filesystem itu 2 line di Dockerfile — worth it.
Q: Bagaimana test security configuration? A: Gunakan tools yang disebutkan di atas (Security Headers, SSL Labs, Trivy). Lakukan penetration testing regular.
Artikel ini ditulis oleh developer Indonesia, untuk developer Indonesia. Kalau berguna, jangan lupa share ke teman-teman!
Artikel ini ditulis oleh developer Indonesia, untuk developer Indonesia.




