nginx로 Node 서버 배포하기 (systemd + HTTPS)
Node 서버를 localhost로 띄우고 nginx로 프록시한 뒤, certbot으로 Let’s Encrypt SSL까지 한 번에 구성하는 흐름 가이드
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) 문제일 수 있습니다.