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-src

Default policy untuk semua resource

'self'

script-src

Sumber JavaScript yang boleh dijalankan

'self' 'unsafe-inline'

style-src

Sumber CSS

'self' 'unsafe-inline'

img-src

Sumber gambar

'self' data: https://images.unsplash.com

connect-src

Sumber untuk fetch/XHR/WebSocket

'self'

frame-ancestors

Siapa yang boleh embed iframe

'none'

object-src

Sumber plugin (Flash, Java)

'none'

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-src untuk 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

  1. Jangan commit .env ke git

# .gitignore
.env
.env.*
!.env.example
  1. 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"
  1. Generate secrets yang strong

# Generate random string untuk JWT secret
openssl rand -base64 32

# Atau gunakan uuid
uuidgen
  1. 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.