홈서버(오라클 클라우드 프리티어)에서 Docker로 여러 서비스(Nextcloud, 원격 데스크톱 환경 등)를 운영하던 중, 특정 서비스에 모바일에서만 접속이 안 되는 문제를 겪었습니다. 원인을 추적하다 보니 HSTS, TLS 인증서, Docker 네트워크까지 얽힌 문제였고, 해결 과정에서 배운 것들을 정리합니다.
1. 증상: PC는 되는데 모바일은 안 됨
Docker로 띄운 원격 데스크톱 서비스(예: webtop 같은 브라우저 기반 원격 데스크톱 이미지)를 https://mydomain.duckdns.org:3001/ 형태로 포트를 직접 지정해 접속하고 있었습니다.
- PC 브라우저: 정상 접속됨
- 모바일 브라우저: “이 사이트는 비정상적인 인증서를 사용하고 있습니다”라며 완전 차단, 우회 옵션도 없음

같은 URL인데 왜 기기마다 다르게 동작하는지가 핵심 의문이었습니다.
2. 원인 분석
2-1. HSTS는 도메인 단위로, 포트를 구분하지 않는다
메인 도메인이 이미 Nextcloud 같은 서비스를 정식 인증서(Let’s Encrypt) + HTTPS로 서비스하고 있었고, 그 과정에서 Strict-Transport-Security 헤더(HSTS)를 브라우저가 받은 적이 있었습니다.
HSTS의 동작 방식:
“이 도메인은 앞으로 무조건 HTTPS로만 접속해라” — 이 정책은 포트 번호와 무관하게 도메인 전체에 적용됩니다.
그래서 :3001 같은 별도 포트로 접속해도 브라우저는 자동으로 HTTPS를 강제 적용합니다.
2-2. 백엔드 서비스가 자체 서명 인증서를 쓰고 있었다
문제의 원격 데스크톱 컨테이너는 내부적으로 자체 서명(self-signed) 인증서로 HTTPS만 서비스하고 있었습니다 (http://로 접속 시 400 Bad Request 응답, https://로는 정상 응답하는 것으로 확인).
- PC: 예전에 “위험을 감수하고 계속” 같은 예외 처리를 했거나, 아직 HSTS 캐시가 없어서 우회되고 있었을 뿐
- 모바일: HSTS + 신뢰할 수 없는 인증서 조합을 강하게 차단, PC처럼 예외 처리 UI 자체가 없음
즉, “PC는 되고 모바일은 안 된다”는 건 두 기기의 문제가 아니라, 각 기기가 그동안 저장해온 보안 정책(HSTS 캐시) 상태 차이였습니다.
3. 해결 방향: Reverse Proxy로 SSL Bridging
가장 안정적인 해결책은, 모든 트래픽을 정식 인증서를 가진 nginx가 받아서, 내부적으로만 백엔드 서비스와 통신하도록 구조를 바꾸는 것이었습니다.
[브라우저] --https(정식 인증서)--> [nginx] --https(자체서명, 검증 생략)--> [백엔드 서비스]
이 방식을 SSL Bridging이라고 부르며, 핵심 nginx 설정은 다음과 같습니다.
nginx
server {
listen 8443 ssl;
server_name mydomain.duckdns.org;
ssl_certificate /etc/letsencrypt/live/mydomain.duckdns.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mydomain.duckdns.org/privkey.pem;
location / {
proxy_pass https://backend-container:3001;
proxy_ssl_verify off; # 자체 서명 인증서라 검증 생략
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;
# 원격 데스크톱류 서비스는 WebSocket을 쓰는 경우가 많아 필수
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
이렇게 하면 사용자는 정식 인증서로 접속하고, nginx가 내부에서만 자체 서명 인증서를 신뢰하는 방식이라 모바일에서도 문제없이 접속됩니다.
참고: “같은 요청을 HTTPS로 두 번 암복호화하면 느려지지 않나?”라는 의문이 들 수 있는데, 같은 서버 내부(Docker 네트워크)에서 일어나는 통신이라 왕복 지연시간이 거의 없고, TLS 커넥션도 재사용되기 때문에 개인 사용 규모에서는 성능 저하가 체감되지 않습니다.
서브도메인 vs 같은 도메인 + 다른 포트
서비스를 분리하는 방법은 크게 두 가지입니다.
| 새 서브도메인 | 같은 도메인 + 새 포트 | |
|---|---|---|
| 예시 | service.duckdns.org | mydomain.duckdns.org:8443 |
| DNS 추가 등록 | 필요 | 불필요 |
| 인증서 | 새로 발급 필요 | 기존 것 재사용 가능 |
| 접속 시 | 포트 몰라도 됨 | 포트 번호 기억 필요 |
원격 데스크톱처럼 노출 범위를 최소화하고 싶은 서비스라면, 기존 인증서를 재사용할 수 있는 “같은 도메인 + 새 포트” 방식이 간단합니다. 다만 GUI 웹앱(경로 안에 절대경로로 리소스가 박혀있는 앱)의 경우, 경로(path)로 구분하는 방식은 피하는 게 좋습니다. CSS/JS 경로, WebSocket 연결 경로가 도메인 루트 기준으로 하드코딩된 경우가 많아 화면이 깨지거나 아예 접속이 안 될 수 있기 때문입니다.
4. 삽질 포인트: Docker 네트워크와 포트 매핑
설정을 반영하는 과정에서 여러 번 막혔는데, 정리하면 다음과 같습니다.
4-1. host not found in upstream
nginx가 proxy_pass로 지정한 컨테이너 이름을 못 찾는 에러입니다. Docker는 같은 네트워크에 속한 컨테이너끼리만 이름으로 서로를 찾을 수 있기 때문에, nginx 컨테이너와 백엔드 컨테이너가 같은 Docker 네트워크에 있는지 먼저 확인해야 합니다.
bash
docker inspect <컨테이너이름> --format '{{json .NetworkSettings.Networks}}'
두 컨테이너가 다른 네트워크에 있다면 연결해줍니다.
bash
docker network connect <네트워크이름> <컨테이너이름>
4-2. 새 포트를 열었는데 호스트에서 접속이 안 됨
nginx 설정 파일 안에서 listen 8443 ssl;을 추가해도, nginx가 떠 있는 Docker 컨테이너 자체의 포트 매핑(docker-compose.yml의 ports:)에 그 포트가 없으면 호스트 밖으로 노출되지 않습니다.
yaml
services:
proxy:
ports:
- "80:80"
- "443:443"
- "8443:8443" # 새 포트 추가 필수
4-3. docker network connect는 컨테이너 재생성 시 사라진다
docker network connect로 수동 연결한 네트워크는 docker restart나 서버 재부팅에는 유지되지만, docker compose down → up처럼 컨테이너를 삭제하고 새로 만드는 경우엔 사라집니다. docker-compose.yml 파일에 기록되지 않은 연결이기 때문입니다.
이 문제로 인해, 포트 설정을 바꾸고 컨테이너를 재생성했더니 예전에 수동으로 연결해뒀던, 전혀 관계없는 다른 서비스용 네트워크 연결까지 같이 날아가면서 nginx 전체가 부팅 실패(재시작 반복) 상태에 빠지는 부작용을 겪었습니다.
해결책은 수동 연결 대신 compose 파일에 정식으로 명시하는 것입니다.
yaml
services:
proxy:
networks:
- web-public
- other-service-net
networks:
web-public:
external: true
other-service-net:
external: true
external: true는 “이 네트워크를 새로 만들지 말고, 이미 존재하는 걸 그대로 사용하라”는 뜻입니다. YAML 안에서 부르는 이름과 실제 Docker 네트워크 이름이 다르면 name: 속성으로 매핑해줄 수 있습니다.
yaml
networks:
my-alias:
external: true
name: actual-network-name
4-4. Docker Compose 기본 네트워크 이름 규칙
docker-compose.yml에 networks:를 아예 명시하지 않으면, Compose는 자동으로 다음 규칙의 네트워크를 만듭니다.
<compose 파일이 있는 폴더 이름>_default
예를 들어 /service-a/docker-compose.yml이라면 service-a_default가 됩니다. 이 규칙만 알면 다른 컨테이너와 네트워크를 연결할 때 이름을 추측하거나 확인하기 쉽습니다.
bash
docker inspect <컨테이너이름> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}'
4-5. 컨테이너 내부 포트와 호스트 포트 구분하기
docker-compose.yml의 ports: - "3010:3000"은 **”호스트의 3010번” → “컨테이너 내부의 3000번”**으로 매핑한다는 뜻입니다. nginx에서 proxy_pass로 그 컨테이너를 가리킬 때는 **컨테이너 내부 포트(3000)**를 써야지, 호스트용 포트(3010)를 쓰면 안 됩니다. 둘은 서로 다른 네임스페이스에 있기 때문입니다.
또한, nginx를 거쳐서만 접속하게 만든 서비스라면 굳이 호스트에 포트를 직접 노출할 필요가 없습니다. ports: 항목 자체를 제거하면, 외부에서 해당 포트로 직접(인증서/프록시 없이) 접근하는 걸 원천적으로 막을 수 있어 보안상 더 안전합니다.
5. 마무리 정리
이번 트러블슈팅에서 배운 핵심 포인트를 정리하면:
- HSTS는 포트를 구분하지 않는다 — 한 번 저장되면 도메인 전체에 HTTPS가 강제된다.
- 모바일 브라우저는 인증서 예외 처리 UI가 없다 — PC에서 되던 게 모바일에서 안 되면 HSTS + 인증서 문제를 의심해볼 것.
- Reverse Proxy로 SSL Bridging하면 백엔드의 자체 서명 인증서 문제를 깔끔하게 해결할 수 있다.
- Docker 컨테이너 간 통신은 같은 네트워크 안에서만 가능하다 —
docker network connect는 임시방편이고, 영구적으로는 compose 파일에 명시해야 한다. - 호스트 포트와 컨테이너 내부 포트를 헷갈리지 말 것 —
proxy_pass에는 컨테이너 내부 포트를 써야 한다.
여러 서비스를 하나의 도메인/서버에서 운영하다 보면 이런 네트워크 얽힘 문제를 자주 만나게 되는데, 결국 “무엇이 어느 네트워크에 있는지”를 명확히 하고 그걸 설정 파일에 정식으로 기록해두는 습관이 제일 중요한 것 같습니다.