nginx로 Node 서버 배포하기 (systemd + HTTPS)

Node 서버를 localhost로 띄우고 nginx로 프록시한 뒤, certbot으로 Let’s Encrypt SSL까지 한 번에 구성하는 흐름 가이드

분야: DevOps/인프라 시리즈: Nginx nginxnodejssystemdhttpssslletsencryptcertbotenv

Node 서버를 서버 내부(localhost) 에서만 실행하고, 외부 트래픽은 nginx가 받아서 프록시하는 방식은 가장 흔한 운영 패턴입니다. 여기에 Let’s Encrypt로 SSL까지 붙이면, “웬만한 서비스 배포”의 기본 뼈대가 완성됩니다.

이 글은 초보자가 따라 하기 쉽도록 흐름(1→2→3…) 중심으로 구성했고, 중간에 “원샷 스크립트”도 제공합니다.

예시는 익명화되어 있으며, example.com을 사용합니다.

0) 오늘 만들 것(구성도)

Internet
  └─ https://example.com
       ├─ /            -> 정적 파일(선택)
       └─ /api/*       -> nginx -> http://127.0.0.1:3000/*

systemd
  └─ myapp.service -> node server.js (PORT=3000)

1) 준비물 체크

  • 도메인 + DNS A 레코드가 서버 IP를 가리킨다
  • 방화벽/보안그룹에서 80/443 포트 오픈
  • 서버 OS는 Ubuntu 계열 가정

2) (선택) 원샷 명령줄: nginx + Node + systemd + HTTPS

아래 스크립트는 “샘플 Node 서버 생성 → systemd 등록 → nginx 설정 → certbot 발급/리다이렉트 → 갱신 테스트”까지 한 번에 진행합니다.

  • 기존 운영 중인 nginx 설정이 있다면, 그대로 실행하지 말고 필요한 부분만 가져가세요.
  • DOMAIN, EMAIL, APP_NAME만 바꿔서 사용합니다.
export DOMAIN="example.com"
export EMAIL="you@example.com"
export APP_NAME="myapp"
export APP_PORT="3000"

sudo bash -euo pipefail <<BASH
set -euo pipefail

: "\${DOMAIN:?DOMAIN is required}"
: "\${EMAIL:?EMAIL is required}"
: "\${APP_NAME:?APP_NAME is required}"
: "\${APP_PORT:?APP_PORT is required}"

apt-get update -y
apt-get install -y nginx certbot python3-certbot-nginx nodejs

id -u "\${APP_NAME}" >/dev/null 2>&1 || useradd --system --create-home --shell /usr/sbin/nologin "\${APP_NAME}"

mkdir -p "/srv/\${APP_NAME}/current"
chown -R "\${APP_NAME}:\${APP_NAME}" "/srv/\${APP_NAME}"

cat >"/srv/\${APP_NAME}/current/server.js" <<'JS'
const http = require("http");

const port = Number(process.env.PORT || 3000);

const server = http.createServer((req, res) => {
  if (!req.url) {
    res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
    res.end("bad request");
    return;
  }

  if (req.url === "/health") {
    res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
    res.end("ok");
    return;
  }

  if (req.url === "/hello") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ ok: true, message: "hello" }));
    return;
  }

  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
  res.end("not found");
});

server.listen(port, "127.0.0.1", () => {
  console.log(\`listening on http://127.0.0.1:\${port}\`);
});
JS

chown "\${APP_NAME}:\${APP_NAME}" "/srv/\${APP_NAME}/current/server.js"

mkdir -p "/etc/\${APP_NAME}"
cat >"/etc/\${APP_NAME}/\${APP_NAME}.env" <<ENV
NODE_ENV=production
PORT=\${APP_PORT}
ENV
chmod 600 "/etc/\${APP_NAME}/\${APP_NAME}.env"

cat >"/etc/systemd/system/\${APP_NAME}.service" <<UNIT
[Unit]
Description=\${APP_NAME}
After=network.target

[Service]
Type=simple
User=\${APP_NAME}
WorkingDirectory=/srv/\${APP_NAME}/current
EnvironmentFile=/etc/\${APP_NAME}/\${APP_NAME}.env
ExecStart=/usr/bin/node /srv/\${APP_NAME}/current/server.js
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now "\${APP_NAME}.service"

mkdir -p "/var/www/\${DOMAIN}/html"
mkdir -p /var/www/letsencrypt
echo "It works" >"/var/www/\${DOMAIN}/html/index.html"

cat >"/etc/nginx/sites-available/\${DOMAIN}" <<NGINX
server {
  listen 80;
  listen [::]:80;
  server_name \${DOMAIN};

  root /var/www/\${DOMAIN}/html;
  index index.html;

  # LetsEncrypt(ACME)
  location ^~ /.well-known/acme-challenge/ {
    root /var/www/letsencrypt;
  }

  # 민감 파일 차단(실수 방지)
  location ~ /\\.(?!well-known) {
    return 404;
  }

  # 정적 페이지(선택): 필요 없으면 제거
  location / {
    try_files \\$uri \\$uri/ =404;
  }

  # API: https://example.com/api/hello -> http://127.0.0.1:3000/hello
  location /api/ {
    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_pass http://127.0.0.1:\${APP_PORT}/;
  }
}
NGINX

ln -sf "/etc/nginx/sites-available/\${DOMAIN}" "/etc/nginx/sites-enabled/\${DOMAIN}"
rm -f /etc/nginx/sites-enabled/default || true

nginx -t
systemctl reload nginx

certbot --nginx \\
  -d "\${DOMAIN}" \\
  --non-interactive \\
  --agree-tos \\
  -m "\${EMAIL}" \\
  --redirect

certbot renew --dry-run

echo
echo "Done:"
echo "  https://\${DOMAIN}/"
echo "  https://\${DOMAIN}/api/hello"
BASH

3) 단계별로 따라 하기 (원리 이해)

3-1) Node 서버는 “외부에 직접 노출하지 않기”

Node 서버는 127.0.0.1:3000 같은 localhost에만 바인딩하고, 외부는 nginx가 받아줍니다.

curl -fsS http://127.0.0.1:3000/health

3-2) systemd로 “항상 켜져 있게” 만들기

운영에서는 “터미널에서 node 실행” 대신, systemd로 서비스 등록이 기본입니다.

관련 문서: systemd: Node 서비스 유닛 템플릿 (EnvironmentFile 포함)

3-3) nginx에서 /api를 프록시하기

가장 흔한 실수는 proxy_pass의 슬래시(/) 때문에 경로가 꼬이는 경우입니다.

location /api/ {
  proxy_pass http://127.0.0.1:3000/;
}

위 설정이면 /api/hello가 업스트림에서는 /hello로 들어갑니다.

관련 템플릿: nginx: Node API 리버스 프록시 기본

3-4) HTTPS 붙이기 (certbot)

관련 문서: Let’s Encrypt / certbot 운영 체크리스트

4) 웬만한 변형 케이스

4-1) SPA(React/Vue)라서 새로고침하면 404가 난다

SPA는 라우팅을 브라우저에서 처리하므로, nginx에서 fallback이 필요합니다.

템플릿: nginx: SPA fallback

4-2) WebSocket이 끊긴다

nginx에서 Upgrade/Connection 헤더 설정이 필요합니다.

템플릿: nginx: WebSocket 프록시

4-3) 실수로 .env/.git가 열릴까 불안하다

기본 차단 룰을 넣어 “사람 실수”를 방어합니다.

템플릿: nginx: 민감 파일(.env/.git/백업) 차단

4-4) 한 도메인에 정적/API/WS/서브앱을 다 붙이고 싶다

가이드: nginx에서 한 도메인에 정적/API/WebSocket/서브앱 붙이기

5) 가장 흔한 장애 3가지 (빠른 체크)

5-1) 502 Bad Gateway

  • Node가 떠 있나: systemctl status myapp
  • 포트가 맞나: ss -ltnp | rg :3000
  • nginx 업스트림이 맞나: nginx -T | rg proxy_pass -n

5-2) certbot 발급 실패

  • DNS가 맞나: dig +short example.com
  • 80 포트가 열렸나: 외부에서 http://example.com/.well-known/acme-challenge/test 접근 가능해야 함

5-3) 쿠키/세션이 프록시 뒤에서 꼬인다

프록시 뒤 HTTPS 인식(trust proxy) 문제일 수 있습니다.

가이드: 세션/쿠키가 유지되지 않을 때 체크리스트

같이 보면 좋은 문서/도구

관련 가이드