synology / self-hosting / security / performance

웹 운영

Web Station nginx에서 Node 컨테이너로 직접 프록시한 기록

Apache에 mod_proxy_http가 없는 Web Station 가상 호스트에서 nginx user.conf로 로컬 Node 컨테이너를 연결하고 문서 루트 노출을 막은 기록입니다.

Node로 만든 작업 대시보드를 NAS의 Web Station 가상 호스트에 올릴 때, 소스와 SQLite 데이터베이스가 문서 루트 안에 함께 있는 구성이었습니다. 컨테이너는 로컬 주소에만 포트를 바인딩했지만, 공개 요청은 Web Station의 nginx와 Apache를 거쳐야 했습니다. 단순히 화면이 열린다고 끝낼 일이 아니라, 요청이 어느 계층에서 처리되는지와 프록시 설정이 사라졌을 때 파일이 노출되지 않는지를 함께 확인해야 했습니다.

처음에는 Apache에서 Node 컨테이너로 HTTP 프록시를 연결하려 했습니다. 그러나 이 NAS의 Apache 패키지에는 그 경로에 필요한 모듈이 없었습니다. 결국 Web Station이 생성한 가상 호스트의 nginx 사용자 include 파일에서 Node 컨테이너로 직접 프록시하는 방식으로 바꿨습니다.

Apache에는 HTTP 업스트림을 맡을 proxy_http_module이 없었습니다

Web Station 가상 호스트는 nginx가 요청을 받은 뒤 내부 Apache로 넘기는 구조였습니다. 따라서 먼저 Apache가 HTTP 업스트림을 프록시할 수 있는지를 확인했습니다. Apache 설정에서 로드된 프록시 모듈을 확인한 결과는 아래 세 항목뿐이었습니다.

LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so
LoadModule proxy_uwsgi_module modules/mod_proxy_uwsgi.so

proxy_module 자체는 있었지만 proxy_http_module은 없었습니다. Apache의 mod_proxy_http 문서도 HTTP와 HTTPS 요청 프록시는 이 모듈이 담당하며, mod_proxy와 함께 필요하다고 설명합니다. 이 상태에서는 Apache의 ProxyPass로 http:// 업스트림을 지정하는 방법을 사용할 수 없다고 판단했습니다.

PHP 사이트가 정상 동작한다고 해서 HTTP 프록시까지 가능한 것은 아니었습니다. 이 NAS의 PHP 요청은 proxy_fcgi를 통해 PHP-FPM 소켓으로 전달되고 있었습니다. 따라서 PHP 경로에서는 HTTP 프록시 모듈의 부재가 드러나지 않았고, HTTP로 통신하는 Node 컨테이너를 붙이려는 시점에만 차이가 문제가 됐습니다.

Apache 설정을 더 바꾸기보다, 이미 Web Station 가상 호스트 앞단에 있는 nginx에서 요청을 직접 처리하는 편이 현재 패키지 구성에 맞았습니다. 이 판단은 Apache에 어떤 모듈이 설치되어 있을지 추정한 것이 아니라, 실제로 로드된 모듈 목록과 컨테이너가 HTTP 포트로 대기하는 상태를 함께 보고 내렸습니다.

Node 컨테이너는 NAS 로컬 주소에만 바인딩했습니다

프록시 대상이 될 컨테이너도 별도로 확인했습니다. 컨테이너 이미지는 node:22-alpine이었고, 상태는 Up이었습니다. 포트 표시는 127.0.0.1:4173->4173/tcp였으며, PortBindings 확인 결과도 로컬 주소의 4173 하나뿐이었습니다.

이 바인딩에서는 외부 클라이언트가 컨테이너 포트에 직접 접속하지 못합니다. nginx만 NAS 내부의 127.0.0.1:4173으로 연결해 Node 애플리케이션에 요청을 전달합니다. 공개 서비스의 진입점과 애플리케이션 포트를 분리한 구성입니다.

소스 폴더는 컨테이너의 /app에 마운트했습니다. 데이터는 소스 트리 안의 SQLite 파일을 사용했습니다. 로그인 관련 값은 별도 env 파일에 두고 권한을 600으로 설정했으며, 일반 계정으로 쓸 수 없는 Docker 공유 폴더 대신 사용자 홈 영역에 보관했습니다.

2026-10-13에 확인한 프록시 조건과 공개 응답
확인 항목확인 결과구성 판단
Apache 프록시 모듈proxy_module, proxy_fcgi_module, proxy_uwsgi_moduleproxy_http_module 없음
컨테이너node:22-alpine, UpNode 실행 상태 확인
컨테이너 포트127.0.0.1:4173->4173/tcp로컬 nginx 프록시 대상으로만 사용
공개 주소의 /HTTP 303로그인 화면으로 이동
공개 주소의 /data/workflow.dbHTTP 303정적 파일 제공 대신 Node가 처리

가상 호스트의 user.conf에서 모든 요청을 Node로 보냈습니다

Web Station이 만든 가상 호스트별 conf.d 폴더의 user.conf에 nginx location 블록을 넣었습니다. 이 파일은 Web Station nginx 설정 안에 가상 호스트별로 포함되는 파일입니다. 파일을 쓸 때는 root 권한이 필요했습니다.

location ^~ /로 루트부터 들어오는 요청을 잡고, Apache로 전달하지 않은 채 로컬 Node 컨테이너로 보냈습니다. Host와 전달 헤더도 함께 지정했습니다. nginx 프록시 모듈 문서에서 설명하는 proxy_pass와 proxy_set_header의 용도에 맞춘 구성입니다.

location ^~ / {
    proxy_pass http://127.0.0.1:4173;
    proxy_http_version 1.1;
    proxy_set_header Host $http_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_buffering off;
    proxy_read_timeout 3600s;
}

proxy_http_version 1.1을 명시했습니다. nginx 문서상 프록시 HTTP 버전의 기본값은 1.0이며, keepalive나 WebSocket에는 1.1이 필요할 수 있습니다. 이 가상 호스트에서는 프록시 연결에 1.1을 사용하도록 고정했습니다.

proxy_buffering off는 응답을 받는 즉시 클라이언트에 전달하도록 하는 값입니다. proxy_read_timeout 3600s는 응답을 읽을 때 연속된 두 읽기 사이에 허용하는 대기 시간입니다. 여기서는 해당 시간을 3600초로 두었습니다.

다른 가상 호스트의 user.conf에는 보안 헤더와 백업 확장자, 점으로 시작하는 경로를 막는 규칙도 있었습니다. 이 대시보드 가상 호스트의 user.conf에는 위 프록시 블록만 넣었습니다. 보안 헤더가 필요하면 같은 파일에 따로 추가해야 합니다.

프록시가 사라졌을 때 문서 루트가 열리지 않게 막았습니다

정상 경로에서는 nginx가 모든 요청을 Node로 넘깁니다. 공개 주소에서 /를 요청했을 때는 HTTP 303으로 로그인 화면으로 이동했고, /data/workflow.db를 요청했을 때도 HTTP 303이었습니다. 데이터베이스 경로가 정적 파일 내용으로 응답되지 않고 애플리케이션 처리 경로로 들어갔음을 확인한 결과입니다.

하지만 user.conf가 없어지거나 적용되지 않으면 요청은 Web Station의 기본 경로대로 Apache에 도달할 수 있습니다. 문서 루트에는 애플리케이션 소스와 SQLite 파일이 있으므로, 그때 정적 파일 제공이 일어나지 않게 별도 방어가 필요했습니다.

문서 루트의 .htaccess에는 아래 한 줄을 넣었습니다. Apache가 문서 루트를 처리하게 되는 상황에서는 소스나 데이터베이스를 그대로 보내는 대신 접근 거부 응답이 나도록 한 것입니다.

Require all denied

이 차단은 문서 루트를 정적 사이트용 디렉터리로 쓰지 않는 현재 배치에 맞춘 것입니다. nginx 프록시가 정상일 때는 Node가 요청을 처리하고, 프록시 경로가 사라졌을 때는 Apache의 문서 루트 제공을 막습니다. 정상 응답과 비정상 경로의 응답 책임을 분리한 셈입니다.

되돌릴 때는 가상 호스트의 user.conf를 지우고 nginx 설정을 다시 읽혔습니다. 다만 이 상태에서는 Apache 경로가 복구되고, .htaccess의 Require all denied 때문에 문서 루트 요청이 403이 될 수 있습니다. 프록시만 제거하고 서비스가 기존처럼 열릴 것이라고 가정하지 않고, .htaccess 처리 결과도 함께 확인해야 합니다.

nginx -s reload

마운트한 소스를 고친 뒤에는 컨테이너 재시작이 필요했습니다

소스 폴더를 컨테이너에 마운트했더라도, 실행 중인 Node 프로세스가 수정된 코드를 자동으로 다시 읽지는 않았습니다. 로그인 화면에서 아이콘을 인증 없이 내려주도록 server.mjs를 수정한 적이 있었지만, 컨테이너를 재시작하지 않아 수정 사항이 반영되지 않았습니다.

당시 로그인 화면의 아이콘은 깨진 상태였습니다. 원인은 마운트 자체가 아니라, Node 프로세스가 시작 시점에 읽은 코드를 계속 사용하고 있었던 데 있었습니다. 실행 중인 작업이 0개인 것을 확인한 뒤 컨테이너를 재시작했습니다.

재시작 뒤에는 아이콘 3종이 모두 HTTP 200으로 응답했습니다. 따라서 이 구성에서 소스 변경 반영은 파일 수정으로 끝나지 않습니다. 실행 중인 작업 여부를 확인하고, 컨테이너를 다시 띄운 뒤, 바꾼 리소스가 기대한 HTTP 응답을 내는지까지 확인하는 절차로 남겼습니다.

같은 구조에서 제가 확인한 순서

먼저 Web Station 가상 호스트가 nginx와 Apache를 거치는 구조인지 확인했습니다. 다음으로 Apache의 로드 모듈에서 proxy_http_module 유무를 따로 봤습니다. mod_proxy가 보인다는 사실만으로 Node 같은 HTTP 업스트림을 연결할 수 있다고 판단하지 않기 위해서였습니다.

그 다음 컨테이너가 실행 중인지와 포트가 로컬 주소에만 바인딩됐는지를 확인했습니다. nginx user.conf를 적용한 뒤에는 공개 주소의 루트뿐 아니라 SQLite 파일 경로도 요청했습니다. 루트 화면만 열리는 결과로는 문서 루트 안의 데이터 파일이 어떤 경로로 처리되는지 확인할 수 없었기 때문입니다.

마지막으로 프록시가 사라졌을 때 Apache가 문서 루트를 제공하지 않도록 .htaccess 차단을 두고, 되돌릴 때의 403 가능성까지 함께 적었습니다. 소스 변경 뒤에는 컨테이너 재시작과 수정 대상의 HTTP 응답 확인을 포함했습니다. 이 순서라면 프록시 연결, 외부 직접 노출, 문서 루트 파일 노출, 코드 반영 누락을 각각 분리해 확인할 수 있었습니다.

확인한 공식 자료

구현 과정에서 판단 기준을 교차 확인한 공식 문서입니다. 글의 사례와 결론은 운영자가 직접 겪은 작업을 바탕으로 작성했습니다.