본문으로 건너뛰기
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsAI 서비스 개발·운영 전체 지도LLMOpsLLM 배포·평가·관측실전 프로젝트AI Agent 프로젝트 실습

    Knowledge

    Docs기술 문서 모음Blog엔지니어링 아티클Plogger개발 기록 피드

    Validate

    Certification3단계 역량 인증 · 준비 중
AI Models
LlamaMistralGemmaDeepSeekQwen
🧱 인프라
인프라 입문 & 로드맵NginxRedis
🤖 AI 실전 개발
AI 실전 입문 & 로드맵Hugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Core
AI 입문 & 로드맵ML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🧠 AI Agent 개발
금융 AI AgentLLM API 서버주식 투자 AgentAIOps AI Agent교육 AI Agent코딩 AI Agent
🌱 Spring Cloud
Spring 입문 & 로드맵Spring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🐳 DevOps
DevOps 입문 & 로드맵LinuxDockerCI/CD|Kubernetes 기본K8s 심화/실무PrometheusGrafana
☁️ 클라우드
클라우드 입문 & 로드맵AWSGCPAzureNCPCloudflare
🎨 Frontend
Frontend 입문 & 로드맵JavaScriptTypeScript|ReactNext.js|VueNuxt
📱 Mobile
Mobile 입문 & 로드맵KotlinAndroidFlutter
⚙️ Backend
Backend 입문 & 로드맵Python 기본FastAPIDjangoFlask|CGoGinNode.js
💾 Database
DB 입문 & 로드맵공통 SQLOracleMySQLPostgreSQL|MongoDB벡터 DB
🧪 검증
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
AI와 AI Agent를 개발하고 운영하기 위한 엔지니어링 학습 플랫폼

Learn

  • 전체 가이드
  • Learning Paths
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • 실전 프로젝트
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (준비 중)

Start Here

  • AI Core 로드맵
  • AI 실전 개발 로드맵
  • Spring Cloud 로드맵
  • DevOps 로드맵
  • 인프라 로드맵

 

  • 클라우드 로드맵
  • Frontend 로드맵
  • Mobile 로드맵
  • Backend 로드맵
  • Database 로드맵
© 2026 AI DevOps Korea. All rights reserved.
이용약관개인정보처리방침Sitemaptestforge.kr
  1. Home
  2. Learn
  3. 인프라
  4. Nginx
nginx.conf 중심 웹 서버 & 리버스 프록시 실무 가이드

🌐 Nginx 완전 가이드

Visitors

nginx.conf를 중심으로 Nginx를 실무 수준으로 다룹니다. 컨텍스트와 요청 처리 순서, server_name·location 매칭 규칙, root/alias·proxy_pass 슬래시 함정, add_header 상속 문제, map 변수, 리버스 프록시 keepalive·WebSocket, TLS, Rate Limit·보안 헤더, 프록시 캐시, 디버깅과 운영용 전체 템플릿까지 정리했습니다.

  • Intermediate · 중급
  • 업데이트 2026.09.19
  • 약 49분 읽기
  • 22개 섹션
  • 예제 코드 44개
🌐
nginx.conf 설계리버스 프록시 & 로드밸런싱정적 파일 서빙HTTPS(TLS) 종료Rate Limit & 보안 하드닝프록시 캐시

목차

0 / 24
  1. 가이드 사용법
  2. 구조 다이어그램
  3. 설치
  4. 설정 구조 기초
  5. nginx.conf 컨텍스트
  6. 요청 처리 순서
  7. 파일 레이아웃 & include
  8. main & events
  9. http 전역 기본값
  10. server 선택 규칙
  11. location 매칭 규칙
  12. root/alias·proxy_pass 함정
  13. 지시어 상속 함정
  14. 변수 & map
  15. 리버스 프록시 & 로드밸런싱
  16. 프록시 심화 (keepalive, WS)
  17. 정적 파일 서빙
  18. 다운로드되는 문제 해결
  19. HTTPS(TLS) 설정
  20. 보안 하드닝 & Rate Limit
  21. 프록시 캐시
  22. 성능 튜닝
  23. 디버깅 & 에러 해결
  24. 운영용 전체 템플릿
목차 24개 섹션
  1. 가이드 사용법
  2. 구조 다이어그램
  3. 설치
  4. 설정 구조 기초
  5. nginx.conf 컨텍스트
  6. 요청 처리 순서
  7. 파일 레이아웃 & include
  8. main & events
  9. http 전역 기본값
  10. server 선택 규칙
  11. location 매칭 규칙
  12. root/alias·proxy_pass 함정
  13. 지시어 상속 함정
  14. 변수 & map
  15. 리버스 프록시 & 로드밸런싱
  16. 프록시 심화 (keepalive, WS)
  17. 정적 파일 서빙
  18. 다운로드되는 문제 해결
  19. HTTPS(TLS) 설정
  20. 보안 하드닝 & Rate Limit
  21. 프록시 캐시
  22. 성능 튜닝
  23. 디버깅 & 에러 해결
  24. 운영용 전체 템플릿

가이드 사용법

읽는 방향

Nginx를 실무 흐름으로 이해하기

nginx.conf를 중심으로 Nginx를 실무 수준으로 다룹니다. 컨텍스트와 요청 처리 순서, server_name·location 매칭 규칙, root/alias·proxy_pass 슬래시 함정, add_header 상속 문제, map 변수, 리버스 프록시 keepalive·WebSocket, TLS, Rate Limit·보안 헤더, 프록시 캐시, 디버깅과 운영용 전체 템플릿까지 정리했습니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.

핵심 관점

인프라 / 운영

설치 명령을 외우기보다 트래픽, 런타임, 관측, 장애 대응이 어떤 순서로 이어지는지 파악합니다.

nginx.conf 설계리버스 프록시 & 로드밸런싱정적 파일 서빙HTTPS(TLS) 종료Rate Limit & 보안 하드닝프록시 캐시

구조 다이어그램

글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Nginx를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.

학습 흐름

다이어그램 렌더링 중…

아키텍처 관점

다이어그램 렌더링 중…

설치

Nginx를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.

운영 서버는 패키지 매니저로 직접 설치해 systemd로 관리하고, 로컬 테스트나 컨테이너 환경에서는 공식 Docker 이미지로 빠르게 띄우는 경우가 많습니다. 설정을 바꾼 뒤에는 nginx -t로 먼저 문법 오류가 없는지 확인하고 나서 reload해야, 오타로 인해 서비스 전체가 중단되는 사고를 막을 수 있습니다.
BASH
# Ubuntu/Debian
sudo apt update && sudo apt install -y nginx
sudo systemctl enable --now nginx

# macOS
brew install nginx

# Docker
docker run -d -p 80:80 -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro nginx

nginx -v            # 버전 확인
nginx -t             # 설정 문법 검사
sudo nginx -s reload # 무중단 설정 재적용

설정 구조

여기서는 설정 구조을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Nginx 설정은 최상위 `nginx.conf`가 `sites-available/*`(사용 가능한 설정)를 `sites-enabled/*`(심볼릭 링크로 활성화된 설정)로 불러오는 구조가 일반적입니다. `server` 블록 하나가 도메인/포트 하나를 담당합니다.
/etc/nginx/sites-available/myappNGINX
server {
    listen 80;
    server_name example.com;

    location / {
        root /var/www/myapp;
        try_files $uri $uri/ /index.html;  # SPA 라우팅
    }
}
지시어역할
listen포트 및 프로토콜(80, 443 ssl 등)
server_name이 서버 블록이 응답할 도메인
locationURL 경로별 처리 규칙
root / try_files정적 파일 루트 경로와 파일 탐색 순서

Tip

`sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/`로 활성화한 뒤 반드시 `nginx -t`로 문법을 검사하고 `reload`하세요.

nginx.conf 컨텍스트 구조

여기서는 nginx.conf 컨텍스트 구조을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

nginx.conf는 계층적인 컨텍스트(context)로 구성됩니다. 상위 컨텍스트의 지시어는 하위로 상속되고, 하위에서 같은 지시어를 다시 쓰면 그 범위에서만 덮어씁니다. 이 상속 규칙을 모르면 "분명 설정했는데 특정 location에서만 안 먹는" 문제를 겪기 쉽습니다.
/etc/nginx/nginx.confNGINX
user  nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;   # 확장자 → Content-Type 매핑표
    default_type  application/octet-stream; # 매핑에 없는 확장자의 기본값

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                     '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
    access_log /var/log/nginx/access.log main;

    sendfile      on;
    keepalive_timeout 65;
    gzip          on;

    # 도메인별 설정은 별도 파일로 분리해 관리한다
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}
컨텍스트역할대표 지시어
main (최상위)워커 프로세스, 로그 등 전역 설정user, worker_processes, error_log, pid
events연결 처리 방식worker_connections, use
http웹 서버 전역 설정 — 모든 server 블록의 부모include mime.types, default_type, sendfile, gzip, log_format
server도메인/포트 하나를 담당하는 가상 호스트listen, server_name, root, index, ssl_certificate
locationURL 경로별 세부 처리 규칙try_files, proxy_pass, alias, return, rewrite

Tip

  • `default_type application/octet-stream;`이 http 컨텍스트에 있으면, mime.types에 없는 확장자는 전부 "알 수 없는 바이너리 파일"로 취급되어 브라우저가 다운로드 창을 띄웁니다 — 아래 "다운로드되는 문제 해결" 섹션에서 바로 다룹니다.
  • location 매칭 우선순위: `location = /path`(정확 일치) → `location ^~ /path`(접두 일치, 정규식 생략) → `location ~/~* /regex`(정규식) → 일반 접두 문자열(최장 일치) 순으로 검사됩니다. 정확히 원하는 location이 먼저 매칭되게 하려면 `=`를 적극 활용하세요.

요청 한 건이 nginx.conf를 통과하는 순서

여기서는 요청 한 건이 nginx.conf를 통과하는 순서을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

nginx.conf를 읽을 때 가장 중요한 감각은 "요청 하나가 어떤 순서로 어떤 블록을 고르는가"입니다. Nginx는 ① 요청이 들어온 IP:포트로 후보 server 블록을 추리고, ② Host 헤더를 server_name과 비교해 server 하나를 고른 뒤, ③ URI를 location 규칙과 비교해 location 하나를 고르고, ④ 그 location 안의 지시어(rewrite → 접근 제어 → try_files/proxy_pass)를 정해진 단계(phase) 순서대로 실행합니다. 설정 파일에 적힌 순서대로 위에서 아래로 실행되는 스크립트가 아니라는 점이 핵심입니다.
다이어그램 렌더링 중…
단계(phase)대표 지시어실무에서 헷갈리는 점
server 선택listen, server_nameHost가 어디에도 안 맞으면 default_server(없으면 첫 번째 server)가 응답합니다.
location 선택location파일에 적힌 순서가 아니라 매칭 규칙의 우선순위로 결정됩니다(정규식끼리만 순서가 의미 있음).
rewriterewrite, return, set, ifreturn은 즉시 응답을 끝내므로 뒤의 지시어가 모두 무시됩니다.
accessallow/deny, limit_req, auth_basicallow/deny는 위에서부터 첫 매칭으로 판단합니다.
contenttry_files, proxy_pass, root/aliaslocation 하나에는 콘텐츠 핸들러가 하나만 동작합니다.
filtergzip, add_headeradd_header는 기본적으로 2xx/3xx 응답에만 붙습니다(always로 확장).

설정 파일 레이아웃 & include 전략

여기서는 설정 파일 레이아웃 & include 전략을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

설정 파일을 어떻게 나누느냐는 배포판과 설치 방식에 따라 다릅니다. Debian/Ubuntu 패키지는 sites-available / sites-enabled 구조를, 공식 nginx.org 패키지와 공식 Docker 이미지는 conf.d/*.conf 구조를 씁니다. 두 방식을 섞으면 같은 server_name이 두 번 로드되어 "conflicting server name" 경고와 함께 엉뚱한 블록이 응답하는 일이 생기므로, 팀에서 한 가지로 통일하세요. 여러 server 블록에서 반복되는 설정은 snippets/ 에 모아 include로 재사용합니다.
/etc/nginx 권장 디렉터리 구조TEXT
/etc/nginx/
├── nginx.conf              # main / events / http 전역 설정만 둔다
├── mime.types              # 확장자 → Content-Type 매핑 (수정하지 않음)
├── conf.d/
│   ├── 00-upstreams.conf   # upstream 블록 모음 (파일명 순서대로 로드)
│   ├── 10-maps.conf        # map 변수 정의
│   ├── api.example.com.conf
│   └── www.example.com.conf
└── snippets/
    ├── proxy-headers.conf  # proxy_set_header 묶음
    ├── security-headers.conf
    └── ssl-params.conf     # TLS 공통 설정
snippets/proxy-headers.confNGINX
proxy_http_version 1.1;
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_set_header X-Request-Id      $request_id;
conf.d/api.example.com.confNGINX
server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;

    include snippets/ssl-params.conf;
    include snippets/security-headers.conf;

    location / {
        include snippets/proxy-headers.conf;
        proxy_pass http://api_backend;
    }
}
설정 확인 명령BASH
sudo nginx -t          # 문법 + 파일 경로 검사
sudo nginx -T          # include까지 모두 펼친 "실제로 로드되는 전체 설정" 출력
sudo nginx -T | grep -n "server_name"   # 중복 server_name 찾기
nginx -V               # 컴파일 옵션·포함된 모듈 확인 (http_v2, stream 등)

Tip

  • "설정을 바꿨는데 반영이 안 된다"면 nginx -T로 실제 로드되는 설정을 먼저 보세요. 편집한 파일이 include 경로에 없거나, sites-enabled에 심볼릭 링크가 빠진 경우가 대부분입니다.
  • 공식 Docker 이미지는 /etc/nginx/templates/*.template 파일을 컨테이너 시작 시 envsubst로 치환해 conf.d/에 생성합니다. 도메인·업스트림 주소를 환경 변수로 주입할 때 이 기능을 쓰면 이미지를 다시 빌드할 필요가 없습니다.
  • include에 와일드카드를 쓰면 파일명 알파벳 순으로 로드됩니다. upstream·map처럼 먼저 정의되어야 하는 설정은 00-, 10- 같은 숫자 접두어로 순서를 고정하세요.

main & events 컨텍스트

여기서는 main & events 컨텍스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

최상위(main)와 events 컨텍스트는 워커 프로세스 수와 동시 연결 한도를 결정합니다. 이론상 최대 동시 연결 수는 worker_processes × worker_connections이며, 리버스 프록시는 클라이언트 연결 1개당 업스트림 연결 1개를 더 쓰므로 실제 수용 가능한 클라이언트는 그 절반 정도입니다. 연결 수를 늘릴 때는 OS의 파일 디스크립터 한도(worker_rlimit_nofile)도 함께 올려야 "Too many open files" 에러를 피할 수 있습니다.
nginx.conf (main / events)NGINX
user  nginx;
worker_processes auto;            # CPU 코어 수만큼 워커 생성
worker_rlimit_nofile 65535;       # 워커당 열 수 있는 파일(소켓 포함) 한도
pid /run/nginx.pid;

error_log /var/log/nginx/error.log warn;

# 동적 모듈은 main 컨텍스트에서 로드
# load_module modules/ngx_http_geoip2_module.so;

events {
    worker_connections 8192;      # 워커 하나당 최대 동시 연결
    multi_accept on;              # 한 번에 대기 중인 연결을 모두 accept
    # use epoll;                  # Linux는 자동 선택되므로 보통 생략
}
지시어기본값권장 출발점비고
worker_processes1autoCPU 바운드 작업(TLS, gzip)이 많을수록 코어 수에 맞추는 효과가 큼
worker_connections5124096 ~ 16384worker_rlimit_nofile보다 작아야 함
worker_rlimit_nofileOS 한도worker_connections × 2 이상systemd의 LimitNOFILE과도 맞춰야 함
error_log 레벨errorwarn (장애 분석 시 info/debug)debug는 --with-debug 빌드에서만 동작

http 컨텍스트 — 전역 기본값 설계

여기서는 http 컨텍스트 — 전역 기본값 설계을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

http 블록은 모든 server 블록의 부모이므로, 여기에 "모든 사이트에 공통으로 적용할 안전한 기본값"을 둡니다. 특히 client_max_body_size(기본 1m)와 각종 timeout은 기본값이 실무 요구와 맞지 않는 경우가 많아, 파일 업로드 413 에러나 느린 API의 504 에러의 원인이 됩니다. 로그는 JSON 형식으로 남기면 Loki·Elasticsearch 같은 수집기에서 필드 단위로 검색·집계할 수 있습니다.
nginx.conf (http)NGINX
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    charset       utf-8;

    server_tokens off;                 # 에러 페이지·Server 헤더에서 버전 숨김

    # ── 전송 최적화 ─────────────────────────────
    sendfile    on;                    # 커널에서 파일을 바로 소켓으로 복사
    tcp_nopush  on;                    # sendfile과 함께: 헤더+데이터를 한 패킷에
    tcp_nodelay on;                    # keepalive 연결에서 작은 패킷 지연 제거

    # ── 타임아웃 & 요청 크기 ─────────────────────
    keepalive_timeout     65s;
    client_header_timeout 15s;
    client_body_timeout   30s;
    send_timeout          30s;
    client_max_body_size  20m;         # 기본 1m → 초과 시 413 Request Entity Too Large

    # ── JSON 액세스 로그 ────────────────────────
    log_format json escape=json '{'
        '"time":"$time_iso8601",'
        '"request_id":"$request_id",'
        '"remote_addr":"$remote_addr",'
        '"host":"$host",'
        '"method":"$request_method",'
        '"uri":"$request_uri",'
        '"status":$status,'
        '"bytes":$body_bytes_sent,'
        '"request_time":$request_time,'
        '"upstream_addr":"$upstream_addr",'
        '"upstream_status":"$upstream_status",'
        '"upstream_response_time":"$upstream_response_time",'
        '"user_agent":"$http_user_agent"'
    '}';
    access_log /var/log/nginx/access.log json buffer=32k flush=5s;

    # ── 압축 ────────────────────────────────────
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_proxied any;                  # 프록시 응답도 압축
    gzip_vary on;                      # Vary: Accept-Encoding (CDN 캐시 분리)
    gzip_types text/plain text/css application/javascript application/json
               application/xml image/svg+xml;

    include /etc/nginx/conf.d/*.conf;
}

Tip

  • $request_time(클라이언트 기준 전체 처리 시간)과 $upstream_response_time(백엔드 응답 시간)을 함께 남기면, 느린 요청이 Nginx·네트워크 문제인지 백엔드 문제인지 로그만으로 구분할 수 있습니다.
  • $request_id를 로그에 남기고 proxy_set_header X-Request-Id로 백엔드에도 넘기면, 한 요청을 Nginx 로그와 애플리케이션 로그에서 같은 id로 추적할 수 있습니다.
  • text/html은 gzip_types에 적지 않아도 항상 압축 대상입니다. 이미 압축된 형식(jpg, png, woff2, zip)은 넣어도 CPU만 낭비합니다.

server 블록 선택 규칙 (listen & server_name)

여기서는 server 블록 선택 규칙 (listen & server_name)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Nginx는 먼저 listen 지시어의 IP:포트로 후보를 좁힌 다음, Host 헤더와 server_name을 비교합니다. 매칭 우선순위는 ① 정확한 이름 → ② *로 시작하는 가장 긴 와일드카드 → ③ *로 끝나는 가장 긴 와일드카드 → ④ 설정 순서상 처음 매칭되는 정규식입니다. 어떤 이름에도 맞지 않으면 그 포트의 default_server가 응답하는데, 이를 명시하지 않으면 "첫 번째로 로드된 server 블록"이 기본값이 되어 IP로 직접 들어온 스캐너 요청에 실제 서비스가 노출됩니다.
conf.d/00-default.conf — 알 수 없는 Host 차단NGINX
# 등록되지 않은 Host(IP 직접 접속, 스캐너)는 응답 없이 연결을 끊는다
server {
    listen 80  default_server;
    listen 443 ssl default_server;
    server_name _;

    ssl_reject_handshake on;   # 1.19.4+: 인증서 없이 TLS 핸드셰이크 자체를 거부
    return 444;                # nginx 전용 코드: 응답 없이 연결 종료
}
conf.d/www.example.com.conf — 정규화 리다이렉트NGINX
# http → https, www → apex 로 한 번에 정규화
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name www.example.com;
    include snippets/ssl-params.conf;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    include snippets/ssl-params.conf;
    root /var/www/example;
}
server_name 매칭 예시NGINX
server_name example.com;                 # ① 정확한 이름
server_name *.example.com;               # ② 앞쪽 와일드카드 (a.example.com, a.b.example.com)
server_name mail.*;                      # ③ 뒤쪽 와일드카드
server_name ~^(?<tenant>[a-z0-9-]+)\.saas\.com$;   # ④ 정규식 + 이름 있는 캡처

# 정규식 캡처는 이후 지시어에서 변수로 사용 가능
# root /var/www/tenants/$tenant;

Tip

  • return 301로 리다이렉트할 때는 rewrite 대신 return을 쓰세요. 정규식 평가가 없어 더 빠르고 의도가 명확합니다. $request_uri는 쿼리 스트링까지 포함하므로 파라미터가 유실되지 않습니다.
  • SaaS처럼 테넌트별 서브도메인을 쓸 때는 정규식 server_name의 이름 있는 캡처로 테넌트를 추출해 root나 proxy_set_header에 활용할 수 있습니다.

location 매칭 규칙 완전정복

여기서는 location 매칭 규칙 완전정복을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

location은 파일에 적힌 순서대로 검사되지 않습니다. Nginx는 ① = 정확 일치가 있으면 즉시 확정하고, ② 접두 문자열 location 중 가장 긴 것을 기억해 둔 뒤, 그것이 ^~ 이면 정규식 검사를 건너뛰고 확정합니다. ③ 아니면 정규식 location(~ 대소문자 구분, ~* 무시)을 파일에 적힌 순서대로 검사해 처음 맞는 것을 쓰고, ④ 정규식이 하나도 맞지 않을 때만 기억해 둔 최장 접두 location을 사용합니다. 즉 "더 구체적인 접두 location을 적었는데 정규식 location이 가로채는" 현상은 규칙대로 동작한 결과입니다.
location 우선순위 실험NGINX
server {
    listen 8080;

    location = /           { return 200 "1. exact /\n"; }
    location ^~ /static/   { return 200 "2. ^~ /static/\n"; }
    location ~* \.(png|jpg|css|js)$ { return 200 "3. regex asset\n"; }
    location /api/         { return 200 "4. prefix /api/\n"; }
    location /             { return 200 "5. prefix / (fallback)\n"; }
}

# curl localhost:8080/                → 1. exact /
# curl localhost:8080/static/app.js   → 2. ^~ /static/    (정규식보다 우선)
# curl localhost:8080/img/logo.png    → 3. regex asset
# curl localhost:8080/api/logo.png    → 3. regex asset    (/api/ 접두보다 정규식이 우선!)
# curl localhost:8080/api/users       → 4. prefix /api/
# curl localhost:8080/about           → 5. prefix / (fallback)
try_files & 이름 있는 locationNGINX
# SPA: 파일 → 디렉터리 → index.html 순으로 탐색
location / {
    try_files $uri $uri/ /index.html;
}

# 정적 파일이 없으면 백엔드로 넘기기 (Rails/Django/Next.js 등)
location / {
    try_files $uri @app;
}
location @app {
    include snippets/proxy-headers.conf;
    proxy_pass http://app_backend;
}
문법의미예: /images/logo.png 요청
location = /images/logo.png정확 일치 — 가장 먼저, 매칭 즉시 종료이 location이 선택됨
location ^~ /images/접두 일치 + 정규식 검사 생략최장 접두가 이것이면 정규식을 보지 않고 선택
location ~* \.(png|jpg)$정규식(대소문자 무시), 파일 순서대로^~ 가 없다면 이것이 /images/ 접두보다 우선
location /images/일반 접두 일치 (최장 일치)정규식이 하나도 맞지 않을 때만 선택
location /모든 요청의 최종 fallback다른 어떤 것도 맞지 않을 때
location @fallback이름 있는 location — 외부 요청은 매칭 불가try_files, error_page의 내부 이동 대상

Tip

  • /api/ 아래 요청이 정적 파일 정규식 location에 가로채지는 문제를 막으려면 location ^~ /api/ 로 선언하세요. ^~ 는 "이 접두가 최장 일치면 정규식은 보지 마라"는 뜻입니다.
  • location 안에 location을 중첩할 수 있지만, 정규식 location 안에 접두 location을 넣는 식의 복잡한 중첩은 읽기 어렵습니다. 대부분은 ^~ 와 = 를 적절히 쓰는 평평한 구조로 해결됩니다.
  • try_files의 마지막 인자는 "파일"이 아니라 내부 리다이렉트 대상(URI, @이름, =코드)입니다. 마지막 인자까지 파일로 착각해 /index.html 파일이 없으면 무한 루프가 나는 경우가 있으니 =404 로 끝나는 형태도 익혀 두세요.

root vs alias, proxy_pass 슬래시 함정

여기서는 root vs alias, proxy_pass 슬래시 함정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

nginx.conf에서 가장 많은 404와 보안 사고를 만드는 두 가지 함정입니다. root는 "root 경로 + 전체 URI"로 파일을 찾고, alias는 "location에 매칭된 부분을 alias 경로로 치환"합니다. proxy_pass도 비슷하게, 주소 뒤에 URI(슬래시 하나라도)가 붙어 있으면 location에 매칭된 부분을 그 URI로 치환하고, 없으면 원래 URI를 그대로 전달합니다.
위험한 alias (off-by-slash 취약점)NGINX
# ❌ location에는 슬래시가 없고 alias에는 있음
location /static {
    alias /var/www/static/;
}
# GET /static../app/.env  →  /var/www/static/../app/.env  (상위 디렉터리 노출!)

# ✅ location과 alias의 끝 슬래시를 반드시 맞춘다
location /static/ {
    alias /var/www/static/;
}
정규식 location + alias / proxy_passNGINX
# 정규식 location에서 alias를 쓰려면 캡처로 경로를 직접 조립해야 한다
location ~ ^/download/(.+\.pdf)$ {
    alias /data/files/$1;
}

# 정규식 location 안의 proxy_pass에는 URI를 붙일 수 없다 (nginx -t 에러)
# 경로를 바꾸려면 rewrite ... break 로 URI를 먼저 수정한다
location ~ ^/api/v1/(.*)$ {
    rewrite ^/api/v1/(.*)$ /$1 break;
    proxy_pass http://api_backend;
}
설정요청실제로 찾는 파일 / 전달 URI
location /static/ { root /var/www; }/static/app.js/var/www/static/app.js
location /static/ { alias /var/www/assets/; }/static/app.js/var/www/assets/app.js
location /api/ { proxy_pass http://be; }/api/usershttp://be/api/users (그대로)
location /api/ { proxy_pass http://be/; }/api/usershttp://be/users (/api/ → / 치환)
location /api/ { proxy_pass http://be/v2/; }/api/usershttp://be/v2/users

Tip

  • 같은 경로 구조라면 alias보다 root가 안전하고 단순합니다. alias는 URL 경로와 디스크 경로가 달라야 할 때만 쓰세요.
  • proxy_pass에 변수를 쓰면(예: proxy_pass http://$backend;) URI 치환이 일어나지 않고, 도메인 이름을 쓰는 경우 resolver 지시어가 필요해집니다. 동작이 달라지므로 꼭 테스트하세요.

지시어 상속 함정 (add_header, proxy_set_header)

여기서는 지시어 상속 함정 (add_header, proxy_set_header)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

상위 컨텍스트의 지시어는 하위로 상속되지만, add_header·proxy_set_header처럼 여러 번 쓸 수 있는 "배열형" 지시어는 하위 블록에 같은 지시어가 하나라도 있으면 상위 값 전체가 사라집니다(합쳐지지 않음). server에 보안 헤더를 걸어 두고 특정 location에 Cache-Control 하나만 추가했다가, 그 location에서 보안 헤더가 전부 빠지는 사고가 대표적입니다.
❌ 보안 헤더가 사라지는 설정NGINX
server {
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        # ↑ 이 location의 응답에는 X-Frame-Options, X-Content-Type-Options가 없다!
    }
}
✅ snippet include로 명시적으로 다시 적용NGINX
# snippets/security-headers.conf
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# conf.d/example.com.conf
server {
    include snippets/security-headers.conf;

    location /assets/ {
        include snippets/security-headers.conf;   # 다시 포함
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}
지시어상속 방식주의
add_header하위에 하나라도 있으면 상위 전체 무시always가 없으면 4xx/5xx 응답에는 붙지 않음
proxy_set_header하위에 하나라도 있으면 상위 전체 무시기본값(Host $proxy_host, Connection close)으로 되돌아감
root, index, client_max_body_size일반 상속 — 하위에서 덮어쓰기location별로 값을 바꿀 수 있음
rewrite, return상속되지 않음 (해당 블록에서만 동작)server 레벨 rewrite는 location 선택 전에 실행

Tip

배열형 지시어는 "상위에만 두거나, 하위에 둘 거면 전부 다시 적는다"는 원칙을 지키고, 그 반복은 snippet include로 해결하는 것이 가장 실수가 적습니다.

변수, map, 그리고 if를 피하는 법

여기서는 변수, map, 그리고 if를 피하는 법을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Nginx 변수는 요청마다 평가되며($host, $uri, $request_uri, $remote_addr, $http_헤더명, $cookie_이름, $arg_파라미터 등), map 블록으로 한 변수에서 다른 변수를 파생할 수 있습니다. location 안의 if는 내부적으로 별도 location을 만드는 방식이라 return·rewrite 외의 지시어와 섞으면 예상과 다르게 동작하는 것으로 유명합니다("If is Evil"). 조건 분기는 가능하면 map으로 바꾸고, if는 return 용도로만 쓰세요.
conf.d/10-maps.confNGINX
# WebSocket 업그레이드 헤더
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# 점검 모드: 사내 IP는 통과, 나머지는 503
geo $maintenance_bypass {
    default      0;
    10.0.0.0/8   1;
    192.168.0.0/16 1;
}

map $maintenance_bypass $maintenance_block {
    1       0;
    default 1;
}

# 봇 User-Agent는 액세스 로그에서 제외
map $http_user_agent $loggable {
    ~*(bot|crawler|spider|healthcheck)  0;
    default                             1;
}

# 요청 경로에 따라 캐시 정책 결정
map $uri $cache_control {
    ~*\.(js|css|woff2)$   "public, max-age=31536000, immutable";
    ~*\.html$             "no-cache";
    default               "no-store";
}
map 변수 사용NGINX
server {
    access_log /var/log/nginx/access.log json if=$loggable;

    location / {
        # 점검 중에만 아래 한 줄의 주석을 풀고 reload — 사내 IP는 통과
        # if ($maintenance_block) { return 503; }

        add_header Cache-Control $cache_control always;
        try_files $uri $uri/ /index.html;
    }
}

Tip

  • map은 http 컨텍스트에만 선언할 수 있고, 값이 실제로 사용될 때만 평가되므로 map을 많이 만들어도 성능 부담이 거의 없습니다.
  • $uri는 디코딩·정규화된 현재 URI(내부 리다이렉트 후 바뀔 수 있음), $request_uri는 클라이언트가 보낸 원본 URI + 쿼리입니다. 리다이렉트에는 $request_uri, 파일 탐색에는 $uri를 씁니다.

리버스 프록시 & 로드밸런싱

여기서는 리버스 프록시 & 로드밸런싱을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Nginx 뒤에 여러 백엔드 인스턴스를 두면 `upstream` 블록으로 요청을 분산할 수 있습니다. 기본은 라운드로빈이며, 세션 고정이 필요하면 `ip_hash`를 씁니다.
다이어그램 렌더링 중…
reverse-proxy.confNGINX
upstream backend {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    # ip_hash;   # 같은 클라이언트를 같은 서버로 고정하고 싶을 때
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://backend;
        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;
    }
}

Tip

`proxy_set_header`를 빠뜨리면 백엔드 애플리케이션이 실제 클라이언트 IP 대신 Nginx의 내부 IP만 보게 됩니다 — 로그·Rate Limit 기준이 틀어지니 항상 함께 설정하세요.

리버스 프록시 심화 — keepalive, 타임아웃, WebSocket

여기서는 리버스 프록시 심화 — keepalive, 타임아웃, WebSocket을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

기본 proxy_pass만으로는 운영 트래픽에서 성능과 안정성이 부족합니다. Nginx는 기본적으로 업스트림과 HTTP/1.0으로 매 요청마다 새 TCP 연결을 맺기 때문에, upstream keepalive를 켜서 연결을 재사용해야 합니다(이때 proxy_http_version 1.1과 빈 Connection 헤더가 필수). 또한 백엔드 장애 시 다른 서버로 넘길 조건(proxy_next_upstream)과 타임아웃을 API 성격에 맞게 정해야 502/504가 폭증하는 상황을 통제할 수 있습니다.
conf.d/00-upstreams.confNGINX
upstream api_backend {
    least_conn;                                 # 활성 연결이 가장 적은 서버로
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.13:8080 backup;               # 나머지가 모두 죽었을 때만 사용

    keepalive 64;                               # 워커당 유지할 유휴 연결 수
    keepalive_timeout 60s;
}
conf.d/api.example.com.conf (location)NGINX
location /api/ {
    proxy_pass http://api_backend;

    proxy_http_version 1.1;
    proxy_set_header Connection "";             # keepalive 재사용에 필수
    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_connect_timeout 3s;                   # 연결 수립 제한 — 짧게
    proxy_send_timeout    30s;
    proxy_read_timeout    30s;                  # 응답 대기 (기본 60s) → 초과 시 504

    # 연결 실패·타임아웃·502/503일 때 다음 서버로 재시도 (GET 등 멱등 요청만)
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;
}

# WebSocket / SSE
location /ws/ {
    proxy_pass http://api_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;   # 10-maps.conf의 map
    proxy_set_header Host       $host;
    proxy_read_timeout 1h;                      # 유휴 연결이 끊기지 않게
}

location /events/ {                            # Server-Sent Events
    proxy_pass http://api_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;                        # 이벤트를 즉시 흘려보냄
    proxy_cache off;
    proxy_read_timeout 1h;
}
앞단에 LB/CDN이 있을 때 — 실제 클라이언트 IP 복원NGINX
# ngx_http_realip_module: 신뢰하는 프록시가 보낸 헤더에서만 IP를 꺼낸다
set_real_ip_from 10.0.0.0/8;          # 사내 LB 대역
set_real_ip_from 173.245.48.0/20;     # (예) CDN 대역 — 공식 목록으로 관리
real_ip_header    X-Forwarded-For;
real_ip_recursive on;
# 이후 $remote_addr가 실제 클라이언트 IP가 되어 로그·limit_req·allow/deny에 반영된다
증상의미먼저 볼 설정
502 Bad Gateway업스트림에 연결하지 못했거나 응답이 깨짐upstream 주소/포트, 백엔드 프로세스 상태, error.log의 connect() failed
504 Gateway Timeout업스트림이 제한 시간 안에 응답하지 않음proxy_read_timeout, 백엔드의 느린 쿼리
499 (로그에만)응답 전에 클라이언트가 연결을 끊음클라이언트/LB 타임아웃이 Nginx보다 짧은지
upstream sent too big header응답 헤더(쿠키 등)가 버퍼보다 큼proxy_buffer_size 16k, proxy_buffers 8 16k

Tip

  • POST 같은 비멱등 요청은 proxy_next_upstream이 기본적으로 재시도하지 않습니다(non_idempotent를 명시해야 함). 결제·주문 API에서 이 옵션을 켜면 중복 처리가 생길 수 있으니 켜지 마세요.
  • set_real_ip_from 없이 X-Forwarded-For를 그대로 믿으면 클라이언트가 헤더를 위조해 IP 기반 차단·Rate Limit을 우회할 수 있습니다. 반드시 신뢰하는 프록시 대역만 지정하세요.

정적 파일 서빙

여기서는 정적 파일 서빙을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Nginx는 정적 파일을 애플리케이션 서버보다 훨씬 빠르게 서빙합니다. 캐시 헤더와 gzip 압축을 함께 설정하면 프론트엔드 자산 전송 비용을 크게 줄일 수 있습니다.
NGINX
location /static/ {
    alias /var/www/myapp/static/;
    expires 30d;
    add_header Cache-Control "public, immutable";
}

gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1024;

파일이 화면에 안 보이고 다운로드되는 문제 해결

여기서는 파일이 화면에 안 보이고 다운로드되는 문제 해결을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

브라우저가 응답을 화면에 렌더링할지 다운로드할지는 오직 `Content-Type` 응답 헤더로 결정됩니다. Nginx는 서빙하는 파일의 확장자를 `mime.types`에서 찾아 Content-Type을 정하고, 목록에 없으면 `default_type`(기본값 `application/octet-stream` — "알 수 없는 바이너리 파일")으로 폴백합니다. `index.test`처럼 `.html`이 아닌 커스텀 확장자로 요청이 들어오고 내부적으로 실제 html을 이어붙이는 구조라면, 이 폴백 때문에 다운로드 창이 뜨는 경우가 대부분입니다.
diagnose.shBASH
# 실제로 어떤 Content-Type이 내려오는지부터 직접 확인 (http/https 동일하게 확인)
curl -I http://example.com/index.test
curl -I https://example.com/index.test
# → Content-Type: application/octet-stream 이면 아래 해결책 적용
가장 흔한 해결책 — 정적 파일을 내부적으로 .html로 연결NGINX
location = /index.test {
    default_type text/html;      # 이 location의 응답은 항상 text/html로 고정
    try_files /index.html =404;  # 실제 html 파일을 내부 서빙 (URL은 /index.test 그대로 유지, 클라이언트에게 리다이렉트 노출 안 됨)
}
여러 개의 .test 파일을 각각의 .html로 매핑NGINX
location ~ \.test$ {
    rewrite ^(.*)\.test$ $1.html last;
    # last는 URI를 .html로 바꿔 location 매칭을 처음부터 다시 태운다 —
    # 이후 일반 location(/)에서 .html로 정상 서빙되므로 mime.types가 text/html을 올바르게 잡아준다.
}
proxy_pass 뒤 백엔드가 원인인 경우NGINX
location = /index.test {
    proxy_pass http://backend;
    proxy_hide_header Content-Type;      # 백엔드가 보낸 잘못된/누락된 Content-Type 제거
    add_header Content-Type "text/html; charset=utf-8" always;
}
진단(curl -I 결과)원인해결
Content-Type: application/octet-stream`.test`가 mime.types에 없고 default_type으로 폴백try_files/rewrite로 실제 .html로 내부 전달하거나 default_type을 location에서 강제
Content-Disposition: attachment 헤더 존재상위(server/http) 컨텍스트에 다운로드 강제 헤더가 걸려있음해당 add_header를 제거하거나 필요한 location에만 한정
Content-Type이 아예 없거나 비어있음proxy_pass로 넘긴 백엔드가 헤더를 안 보냄proxy_hide_header + add_header로 nginx에서 덮어쓰기

Tip

  • 이 설정은 `listen 80`이든 `listen 443 ssl`이든 동일하게 적용됩니다 — Content-Type 결정 로직은 HTTP/HTTPS와 무관하므로, http·https 서버 블록 둘 다 서비스한다면 같은 location 블록을 양쪽에 동일하게 넣어주면 됩니다.
  • `add_header`는 같은 이름의 헤더를 이미 부모 컨텍스트에서 쓰고 있으면 상속을 막아버리는 특성이 있습니다 — server 블록에 이미 add_header가 있다면 location에도 필요한 걸 전부 다시 적어야 합니다.
  • 수정 후에는 항상 `sudo nginx -t && sudo nginx -s reload` → `curl -I`로 실제 헤더가 바뀌었는지 재확인하세요.

HTTPS(TLS) 설정

여기서는 HTTPS(TLS) 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Let's Encrypt의 certbot을 쓰면 무료 인증서 발급과 자동 갱신을 Nginx 설정에 바로 연동할 수 있습니다. certbot이 Nginx 설정 파일을 직접 읽고 SSL 관련 지시어를 자동으로 추가해주기 때문에, 인증서 경로나 프로토콜 버전을 손으로 하나씩 맞출 필요 없이 명령어 한 번으로 HTTPS 전환이 끝납니다. 사이트가 여러 개라면 TLS 공통 설정은 snippets/ssl-params.conf로 분리해 모든 server 블록이 같은 보안 수준을 갖게 하세요. Nginx 1.25.1부터는 listen 443 ssl http2 대신 별도 지시어 http2 on; 을 사용합니다.
BASH
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
# 이후 80 → 443 리다이렉트와 인증서 설정이 자동으로 추가됨
sudo certbot renew --dry-run   # 자동 갱신 테스트
snippets/ssl-params.confNGINX
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;              # TLS 1.0/1.1 비활성
ssl_prefer_server_ciphers off;              # TLS 1.3 시대에는 클라이언트 선택 존중
ssl_session_cache   shared:SSL:10m;         # 약 4만 세션 — 재접속 시 핸드셰이크 생략
ssl_session_timeout 1d;
ssl_session_tickets off;
snippets/hsts.confNGINX
# HSTS: 브라우저가 이후 1년간 https로만 접속 (적용 전 모든 서브도메인 https 확인!)
# add_header라서 location에 add_header를 추가할 때마다 이 파일도 함께 include한다
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
http + https를 함께 서비스하는 전체 예시NGINX
# 80번 포트 — 필요하면 여기서도 동일한 location을 두고 그대로 서비스 가능
server {
    listen 80;
    server_name example.com www.example.com;
    root /var/www/myapp;

    location = /index.test {
        default_type text/html;
        try_files /index.html =404;
    }
    location / {
        try_files $uri $uri/ =404;
    }
}

# 443번 포트 — TLS가 붙어도 위와 동일한 location을 그대로 사용
server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;
    root /var/www/myapp;

    include snippets/ssl-params.conf;
    include snippets/hsts.conf;

    location = /index.test {
        default_type text/html;
        try_files /index.html =404;
    }
    location / {
        try_files $uri $uri/ =404;
    }
}

Tip

  • certbot이 생성한 설정은 90일마다 자동 갱신되도록 systemd timer가 함께 등록됩니다. `systemctl list-timers`로 확인할 수 있습니다.
  • 보안 관점에서는 보통 80번 포트는 `return 301 https://$host$request_uri;`로 443으로 리다이렉트만 하고, 실제 location 로직은 443 블록에만 두는 걸 권장합니다 — 다만 지금처럼 http로도 직접 서비스해야 하는 상황이라면 위처럼 두 블록에 동일한 location을 유지하면 됩니다.
  • Let's Encrypt는 2025년에 OCSP 응답 서비스를 종료했으므로, 예전 가이드에 있던 ssl_stapling on; 설정은 해당 인증서에서 효과가 없습니다. 다른 CA 인증서를 쓸 때만 검토하세요.
  • TLS 설정(ssl-params.conf)과 HSTS 헤더(hsts.conf)를 분리한 이유는 상속 규칙 때문입니다. ssl_certificate 같은 지시어는 location 안에 쓸 수 없지만, add_header는 location에 하나라도 추가하면 상위 값이 사라지므로 HSTS만 따로 다시 include할 수 있어야 합니다.

보안 하드닝 — 헤더, 접근 제어, Rate Limit

여기서는 보안 하드닝 — 헤더, 접근 제어, Rate Limit을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

인터넷에 노출된 Nginx는 매일 .env, .git, wp-admin 같은 경로를 찾는 스캐너 트래픽을 받습니다. 숨김 파일 차단, 관리 경로 IP 제한, 요청 속도 제한(limit_req)과 동시 연결 제한(limit_conn), 보안 응답 헤더를 기본 템플릿에 넣어 두면 애플리케이션 코드를 건드리지 않고 공격 표면을 크게 줄일 수 있습니다.
http 컨텍스트 — 제한 zone 정의NGINX
# 키: 클라이언트 IP(바이너리 형태가 메모리 효율적), 10MB ≈ 16만 IP
limit_req_zone  $binary_remote_addr zone=api_rl:10m   rate=10r/s;
limit_req_zone  $binary_remote_addr zone=login_rl:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

limit_req_status  429;     # 기본 503 대신 의미가 맞는 429
limit_conn_status 429;
server 컨텍스트 — 적용NGINX
server {
    include snippets/security-headers.conf;

    # 숨김 파일(.env, .git ...) 차단 — 단, ACME 인증용 .well-known은 허용
    location ~ /\.(?!well-known/) {
        deny all;
        access_log off;
        log_not_found off;
    }

    location /api/ {
        limit_req  zone=api_rl burst=20 nodelay;   # 순간 20건까지 허용, 초과분은 429
        limit_conn conn_per_ip 20;
        include snippets/proxy-headers.conf;
        proxy_pass http://api_backend;
    }

    location = /api/auth/login {
        limit_req zone=login_rl burst=5;           # 무차별 대입 방어
        include snippets/proxy-headers.conf;
        proxy_pass http://api_backend;
    }

    location /admin/ {
        allow 10.0.0.0/8;                          # 위에서부터 첫 매칭 규칙 적용
        allow 203.0.113.10;
        deny  all;
        include snippets/proxy-headers.conf;
        proxy_pass http://admin_backend;
    }
}
snippets/security-headers.confNGINX
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CSP는 서비스마다 다르므로 Report-Only로 먼저 관찰한 뒤 적용
# add_header Content-Security-Policy-Report-Only "default-src 'self'" always;
limit_req 옵션동작언제 쓰나
rate=10r/s초당 10건을 균등 간격(100ms)으로 허용평균 허용 속도
burst=20초과 요청을 최대 20개까지 큐에 대기페이지 로딩처럼 짧게 몰리는 정상 트래픽 흡수
nodelay큐에 넣은 요청을 지연 없이 즉시 처리API — 지연보다 빠른 응답/빠른 거절이 나을 때
delay=8처음 8개는 즉시, 나머지 burst는 지연 처리두 방식을 절충할 때

Tip

  • limit_req를 처음 도입할 때는 limit_req_dry_run on; 으로 실제 차단 없이 로그만 남겨 정상 사용자가 걸리는지 먼저 확인하세요.
  • 사내 NAT·모바일 통신사 뒤의 사용자들은 같은 IP를 공유합니다. 로그인 사용자 API는 IP 대신 API 키나 사용자 식별 헤더를 map으로 키로 만드는 방식도 고려하세요.

프록시 캐시 (proxy_cache)

여기서는 프록시 캐시 (proxy_cache)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

자주 조회되지만 자주 바뀌지 않는 API 응답(상품 목록, 공지, 설정값)은 Nginx에서 캐시하면 백엔드 부하와 응답 시간을 동시에 줄일 수 있습니다. proxy_cache_use_stale을 켜면 백엔드가 장애일 때도 마지막으로 성공한 응답을 내려줘 서비스가 버티는 "완충 장치" 역할도 합니다. 반면 사용자별 응답(Authorization·Cookie가 있는 요청)이 캐시되면 다른 사용자에게 개인정보가 노출되므로 캐시 대상에서 반드시 제외해야 합니다.
http 컨텍스트NGINX
proxy_cache_path /var/cache/nginx/api
                 levels=1:2
                 keys_zone=api_cache:50m      # 키 메타데이터 (1MB ≈ 8천 키)
                 max_size=2g
                 inactive=30m                 # 30분간 요청 없으면 삭제
                 use_temp_path=off;
location 적용NGINX
location /api/catalog/ {
    proxy_cache api_cache;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 301 5m;
    proxy_cache_valid 404      1m;

    # 백엔드 장애·갱신 중에는 오래된 캐시라도 응답
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;
    proxy_cache_lock on;                 # 같은 키에 대한 동시 miss를 1건만 백엔드로

    # 개인화된 요청은 캐시 우회 + 저장 금지
    proxy_cache_bypass $http_authorization $cookie_session;
    proxy_no_cache     $http_authorization $cookie_session;

    add_header X-Cache-Status $upstream_cache_status always;   # HIT / MISS / STALE ...
    include snippets/proxy-headers.conf;
    proxy_pass http://api_backend;
}

Tip

  • 백엔드가 Set-Cookie나 Cache-Control: private 헤더를 보내면 Nginx는 기본적으로 그 응답을 캐시하지 않습니다. "캐시가 안 된다"면 X-Cache-Status와 백엔드 응답 헤더부터 확인하세요.
  • 캐시를 강제로 비우려면 캐시 디렉터리를 삭제하면 됩니다(오픈소스 Nginx에는 purge 지시어가 없음). 배포 시 캐시 키에 버전 문자열을 넣는 방식도 자주 씁니다.

성능 튜닝

여기서는 성능 튜닝을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

트래픽이 늘어나면 기본값만으로는 부족해집니다. 튜닝은 "측정 → 한 항목 변경 → 재측정" 순서로 진행하고, 아래 항목은 대부분의 서비스에서 효과가 확인된 출발점입니다. 정적 파일이 많다면 open_file_cache로 파일 메타데이터 조회를 줄이고, 프록시가 주 역할이라면 upstream keepalive와 버퍼 크기가 가장 큰 차이를 만듭니다.
http 컨텍스트 — 튜닝 블록NGINX
open_file_cache          max=10000 inactive=30s;
open_file_cache_valid    60s;
open_file_cache_min_uses 2;
open_file_cache_errors   on;

proxy_buffer_size        16k;      # 응답 헤더용 (큰 쿠키/JWT 대비)
proxy_buffers            8 16k;
proxy_busy_buffers_size  32k;

reset_timedout_connection on;      # 타임아웃된 연결의 메모리를 즉시 회수
stub_status — 연결 상태 관측NGINX
server {
    listen 127.0.0.1:8081;             # 외부 노출 금지
    location = /nginx_status {
        stub_status;
        allow 127.0.0.1;
        deny all;
    }
}
# curl 127.0.0.1:8081/nginx_status
# Active connections: 291
# server accepts handled requests
#  16630948 16630948 31070465
# Reading: 6 Writing: 179 Waiting: 106
# → nginx-prometheus-exporter로 수집해 Grafana에서 시각화
설정설명
worker_processes auto;CPU 코어 수만큼 워커 프로세스를 자동으로 띄움
worker_connections 8192;워커 하나가 처리할 수 있는 최대 동시 연결 수 (worker_rlimit_nofile과 함께)
keepalive_timeout 65;클라이언트와의 연결을 재사용해 TLS 핸드셰이크 비용을 줄임
upstream keepalive 64;백엔드 연결 재사용 — 프록시 지연과 TIME_WAIT 소켓 감소
open_file_cache정적 파일의 fd·메타데이터를 캐시해 stat() 호출 감소
proxy_buffers / proxy_buffer_size큰 응답 헤더·본문을 디스크 대신 메모리에서 처리
limit_req_zone / limit_reqIP별 요청 속도 제한(Rate Limiting)으로 과도한 트래픽 방어

Tip

다음 단계로 docker 가이드에서 Nginx를 컨테이너로 패키징하거나, kubernetes 가이드에서 Ingress로 같은 역할을 클러스터 안에서 수행하는 방법을 이어서 보세요.

설정 디버깅 & 자주 만나는 에러

여기서는 설정 디버깅 & 자주 만나는 에러을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

설정 문제는 대부분 "어떤 server·location이 선택됐는가"와 "실제로 로드된 설정이 무엇인가"만 확인하면 풀립니다. 변경은 항상 nginx -t로 검사한 뒤 reload(무중단)로 반영하고, 디버깅 중에는 응답 헤더에 선택된 location을 표시하는 임시 헤더를 넣으면 추측 대신 사실로 판단할 수 있습니다.
디버깅 루틴BASH
sudo nginx -t && sudo systemctl reload nginx   # 검사 통과 시에만 reload
sudo nginx -T | less                           # 실제 로드된 전체 설정

# Host 헤더를 바꿔가며 어느 server 블록이 응답하는지 확인
curl -sI -H "Host: api.example.com" http://127.0.0.1/health

# TLS SNI까지 포함해 특정 서버 IP로 테스트 (DNS 변경 전 검증)
curl -sI --resolve example.com:443:203.0.113.5 https://example.com/

sudo tail -f /var/log/nginx/error.log         # 대부분의 원인이 여기에 찍힌다
임시 디버그 헤더NGINX
location /api/ {
    add_header X-Debug-Location "api" always;
    add_header X-Debug-Upstream $upstream_addr always;
    # ...
}
# 확인 후 반드시 제거 (내부 주소 노출 방지)
에러 / 로그 메시지원인해결
nginx: [emerg] unknown directive오타, 또는 해당 모듈이 빌드에 없음nginx -V로 모듈 확인, 세미콜론 누락 여부 확인
conflicting server name ... ignored같은 server_name이 두 곳에 정의됨nginx -T | grep server_name 으로 중복 제거
403 Forbidden디렉터리에 index 파일 없음 또는 파일 권한 부족index 지시어, 상위 디렉터리까지 nginx 사용자 x 권한 확인
404 (파일은 존재)root/alias 경로 조합 오류error.log의 "open() ... failed" 경로 확인
413 Request Entity Too Large업로드 크기가 client_max_body_size 초과해당 server/location에서 값 상향
502 + connect() failed (111: Connection refused)백엔드가 떠 있지 않거나 포트 불일치백엔드 상태, upstream 주소 확인
502 + (13: Permission denied) while connectingSELinux가 Nginx의 네트워크 연결 차단setsebool -P httpd_can_network_connect 1
504 upstream timed out백엔드 응답이 proxy_read_timeout 초과느린 엔드포인트만 location 분리 후 타임아웃 조정
rewrite or internal redirection cycletry_files/rewrite가 자기 자신으로 무한 이동try_files 마지막 인자에 =404 사용, rewrite의 last/break 확인

운영용 nginx.conf 전체 템플릿

여기서는 운영용 nginx.conf 전체 템플릿을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

지금까지 다룬 내용을 한 파일 구조로 합친 템플릿입니다. nginx.conf에는 전역 설정만, 사이트별 설정은 conf.d/에, 반복 설정은 snippets/에 두는 구조를 따릅니다. 그대로 복사하기보다 도메인·업스트림 주소·제한 값을 서비스에 맞게 조정한 뒤 nginx -t로 검증하세요.
/etc/nginx/nginx.confNGINX
user  nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;

events {
    worker_connections 8192;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    charset       utf-8;
    server_tokens off;

    sendfile on;  tcp_nopush on;  tcp_nodelay on;
    keepalive_timeout 65s;
    client_max_body_size 20m;
    client_body_timeout 30s;
    send_timeout 30s;
    reset_timedout_connection on;

    log_format json escape=json '{"time":"$time_iso8601","request_id":"$request_id",'
        '"remote_addr":"$remote_addr","host":"$host","method":"$request_method",'
        '"uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,'
        '"request_time":$request_time,"upstream_addr":"$upstream_addr",'
        '"upstream_response_time":"$upstream_response_time","ua":"$http_user_agent"}';
    access_log /var/log/nginx/access.log json buffer=32k flush=5s;

    gzip on;  gzip_comp_level 5;  gzip_min_length 1024;
    gzip_proxied any;  gzip_vary on;
    gzip_types text/plain text/css application/javascript application/json
               application/xml image/svg+xml;

    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;

    limit_req_zone  $binary_remote_addr zone=api_rl:10m rate=10r/s;
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
    limit_req_status 429;
    limit_conn_status 429;

    proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m
                     max_size=2g inactive=30m use_temp_path=off;

    include /etc/nginx/conf.d/*.conf;
}
/etc/nginx/conf.d/example.com.confNGINX
upstream api_backend {
    least_conn;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
    keepalive 64;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# 알 수 없는 Host 차단
server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;
    return 444;
}

# http → https
server {
    listen 80;
    server_name example.com www.example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://example.com$request_uri; }
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    include snippets/ssl-params.conf;
    include snippets/hsts.conf;
    include snippets/security-headers.conf;

    root /var/www/example/dist;
    index index.html;

    location ~ /\.(?!well-known/) { deny all; }

    # 해시가 붙은 빌드 산출물 — 1년 캐시
    location ^~ /assets/ {
        include snippets/hsts.conf;              # add_header 상속 함정 대비 — 다시 포함
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
        access_log off;
    }

    location ^~ /api/ {
        limit_req  zone=api_rl burst=20 nodelay;
        limit_conn conn_per_ip 20;
        include snippets/proxy-headers.conf;
        proxy_set_header Connection "";
        proxy_connect_timeout 3s;
        proxy_read_timeout 30s;
        proxy_pass http://api_backend;
    }

    location ^~ /ws/ {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 1h;
    }

    # SPA 라우팅
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Tip

  • /assets/ location에서 hsts.conf와 security-headers.conf를 다시 include하는 이유는 Cache-Control add_header 하나 때문에 server 레벨의 헤더가 전부 사라지기 때문입니다(지시어 상속 함정 섹션 참고).
  • 템플릿을 Git으로 관리하고 CI에서 docker run --rm -v $PWD:/etc/nginx:ro nginx nginx -t 로 문법 검사를 돌리면, 잘못된 설정이 서버에 배포되기 전에 차단할 수 있습니다.
← 이전 가이드인프라 입문 & 로드맵다음 가이드 →Redis