방화벽을 켰는데 DB에 접속이 된다
VPS를 하나 빌려서 바이브 코딩으로 만든 앱을 올린다고 해보자. 에이전트에게 "Postgres 붙여서 docker compose로 띄워줘"라고 하면 이런 파일을 받기 쉽다.
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: example
ports:
- "5432:5432" # 로컬 DB 툴로 들여다보기 편하라고
예전에 읽은 대로 서버 방화벽도 켜뒀다.
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
5432는 허용 목록에 없으니 막혔을 거라고 생각한다. ufw status를 봐도 5432는 어디에도 없다. 그런데 집 노트북에서 서버IP:5432로 접속해보면 Postgres가 대답한다. 3000번도 마찬가지다. ufw를 거치지 않고 열려 있다.
설정을 잘못한 게 아니다. Docker 공식 문서가 이 상황을 직접 적어두었다. "Docker와 ufw는 방화벽 규칙을 쓰는 방식이 서로 맞지 않는다." 그리고 이유를 이렇게 설명한다. 컨테이너 포트를 공개하면 그 트래픽은 ufw 설정을 거치기 전에 다른 길로 빠지고, 결과적으로 방화벽 설정을 무시하게 된다.
이번 글은 그 "다른 길"이 무엇인지에 관한 이야기다. 길만 이해하면 어디를 막아야 하는지는 저절로 보인다.
-p 5432:5432가 실제로 하는 일
ports: "5432:5432"나 docker run -p 5432:5432는 "서버의 5432번으로 들어온 요청을 컨테이너의 5432번으로 넘겨라"라는 뜻이다. 여기서 앞쪽 숫자 앞에 주소를 안 적으면 Docker는 서버가 가진 모든 주소에 포트를 연다. 공식 문서 표현으로는 IPv4의 0.0.0.0과 IPv6의 [::] 둘 다다. Compose 문서도 같은 말을 한다. 호스트 IP를 지정하지 않으면 모든 네트워크 인터페이스에 바인딩되고 호스트 방화벽 규칙을 우회한다. 서버에 공인 IP가 있으면 컨테이너가 인터넷에 그대로 드러날 수 있다는 경고도 붙어 있다.
포트 공개 문서에는 아예 이런 경고 상자가 있다. "컨테이너 포트를 공개하는 것은 기본적으로 안전하지 않다." 공개한 순간 Docker 호스트뿐 아니라 바깥 세상에서도 접근할 수 있게 된다는 뜻이다.
여기까지는 앞서 다룬 "바인딩 주소" 이야기와 같다. 0.0.0.0에 바인딩하면 모든 문이 열린다. 그런데 보통은 ufw가 그 앞에서 한 번 더 걸러주니 괜찮았다. Docker에서는 왜 그게 안 될까.
패킷이 걷는 두 개의 길
리눅스 방화벽(netfilter, 이걸 다루는 도구가 iptables나 nftables다)은 패킷이 지나가는 길목마다 검문소를 둔다. 이 글에 필요한 건 세 군데다.
- PREROUTING: 패킷이 서버에 막 도착한 순간. 목적지를 바꿔 적을 수 있는 곳이다.
- INPUT: 목적지가 "이 서버 자신"인 패킷이 지나는 곳.
- FORWARD: 이 서버를 거쳐 "다른 곳"으로 넘어가는 패킷이 지나는 곳.
ufw가 지키는 곳은 주로 INPUT이다. ufw allow 22/tcp, ufw default deny incoming 같은 규칙은 "이 서버로 들어오는 연결" 기준이다. 서버에서 직접 돌리는 프로세스, 예를 들어 node server.js나 시스템에 깐 Postgres라면 패킷이 INPUT을 지나니까 ufw에 걸린다.
Docker 컨테이너는 사정이 다르다. 컨테이너는 서버 안에 있지만 네트워크로 보면 172.17.0.2 같은 자기 주소를 가진 별개의 기계다. 그래서 Docker는 포트를 공개할 때 nat 테이블에 규칙을 하나 넣는다. "서버의 5432번으로 온 패킷은 받는 주소를 172.17.0.2:5432로 고쳐 적어라." 이렇게 받는 주소를 바꾸는 걸 DNAT(Destination NAT, 목적지 주소 변환)라고 한다. 공유기에서 "포트 포워딩"을 설정해봤다면 그것과 같은 일이다.
주소를 고쳐 적는 일은 PREROUTING에서 일어난다. 고친 다음에 리눅스는 이 패킷의 목적지를 다시 본다. 172.17.0.2는 서버 자신이 아니다. 그러니 이 패킷은 INPUT이 아니라 FORWARD로 간다. Docker 문서가 말한 "INPUT과 OUTPUT 체인에 닿기 전에 다른 길로 빠진다"는 게 이 장면이다.

그리고 FORWARD에는 Docker가 미리 길을 터둔다. iptables 문서를 보면 Docker는 FORWARD 체인에 자기 체인들(DOCKER-USER, DOCKER-FORWARD 등)로 무조건 넘어가는 규칙을 넣고, 그 안에서 "공개된 포트로 가는 패킷이면 통과"를 판단한다. ufw가 INPUT에 아무리 촘촘히 규칙을 적어도, 이 패킷은 INPUT을 지나지 않는다.
ufw를 좀 아는 사람이라면 "FORWARD용 규칙도 ufw route로 만들 수 있지 않나" 싶을 수 있다. Docker 문서는 여기에 대해 FORWARD 체인에 덧붙인 규칙은 Docker 규칙 뒤에 처리되고, Docker 체인에서 이미 통과시킨 패킷은 그 뒤 규칙이 보지 못한다고 설명한다. 순서상 Docker가 먼저 판정을 내려버리니, 뒤에 붙는 규칙으로는 공개 포트를 확실하게 막을 수 없다.
비유하면 이렇다. 오피스 건물 정문에 경비(ufw)가 있다. 경비는 "이 건물에 볼일 있는 사람"을 검사한다. 그런데 건물 앞에 입주사 전용 안내원(Docker)이 따로 서 있다. 이 안내원은 "5층 입주사 손님이시죠?" 하며 방문증의 목적지를 고쳐 적고, 정문 대신 입주사 전용 통로로 바로 올려보낸다. 경비는 그 손님을 본 적이 없으니 막을 수도 없다. 경비가 일을 안 한 게 아니라, 애초에 경비 앞을 지나가지 않은 것이다.
비유가 어긋나는 지점도 하나 짚어두자. 안내원이 아무나 올려보내는 건 아니다. 내가 -p로 공개한 포트만 이 통로가 열린다. 공개하지 않은 컨테이너 포트는 외부에서 닿지 않는 게 기본값이다(Docker 28.0 이상 기준으로는 다른 기계가 컨테이너 주소로 직접 패킷을 보내는 것도 기본으로 막혀 있다). 문제는 언제나 "공개한 포트"에서 시작된다.
내 서버에서 확인하는 법
지금 돌리는 서버가 이 상태인지는 금방 확인할 수 있다.
docker ps --format 'table {{.Names}}\t{{.Ports}}'
출력의 Ports 칸에 0.0.0.0:5432->5432/tcp나 [::]:5432->5432/tcp가 보이면 모든 주소에 열려 있는 것이다. 127.0.0.1:5432->5432/tcp라면 서버 안에서만 열려 있다.
더 확실한 건 바깥에서 직접 두드려보는 것이다. 서버 안에서 localhost로 접속해보는 건 의미가 없다. 집이나 카페 노트북에서 이렇게 해본다.
nc -zv 서버IP 5432
succeeded나 open이 나오면 인터넷에서 닿는다는 뜻이다. 인터넷 전체 주소를 훑으며 열린 포트를 찾는 스캐너는 늘 돌고 있다. 비밀번호가 example이나 postgres라면 들키는 순간 끝이다.
막는 순서
방법은 여러 가지다. 손이 덜 가고 실수할 여지가 적은 것부터 적는다.
1. DB는 아예 공개하지 않는다
가장 확실한 방법은 ports를 지우는 것이다. 같은 compose 파일 안의 서비스들은 Docker가 만든 내부 네트워크로 서로 이어져 있어서, 앱은 서비스 이름으로 DB를 찾는다.
services:
app:
build: .
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/app
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
# ports 없음. app 컨테이너는 db:5432로 닿는다.
ports가 없으면 Docker는 DNAT 규칙 자체를 만들지 않는다. 바깥에서 들어올 길이 없다. 참고로 compose의 expose는 이름과 달리 포트를 서버 바깥으로 내보내지 않는다. 문서에도 "호스트에 공개되지 않아야 한다"고 적혀 있다. 앱 컨테이너끼리 쓰는 포트를 표시하는 용도다.
로컬 DB 툴(TablePlus, DBeaver 같은)로 들여다보고 싶어서 5432:5432를 열었다면, 그 대신 SSH 터널을 쓰면 된다. 이때는 DB를 서버 안에서만 열어둬야 하니 다음 방법과 같이 쓴다.
2. 꼭 필요하면 127.0.0.1에만 연다
ports:
- "127.0.0.1:5432:5432"
- "127.0.0.1:3000:3000" # 앞단의 Caddy/nginx만 이 포트로 접근
주소를 붙이면 Docker는 서버 자신(루프백)으로 온 연결만 받는다. 앱 앞에 Caddy나 nginx 같은 리버스 프록시를 서버에 직접 깔아 80/443을 받는 구조라면, 앱 포트도 이렇게 묶어두는 게 맞다. 프록시는 서버 안에서 127.0.0.1:3000으로 넘기면 되니까. 로컬 DB 툴은 이렇게 붙는다.
ssh -L 5432:127.0.0.1:5432 user@서버IP
# 이제 내 노트북의 localhost:5432가 서버의 127.0.0.1:5432로 이어진다
여기에 버전 주의가 하나 붙는다. Docker 문서는 28.0.0보다 오래된 버전에서는 같은 L2 구간(같은 스위치에 물린 기계들)에 있는 호스트가 localhost에 공개한 포트에도 닿을 수 있었다고 경고한다. 2023년에 올라온 moby 이슈 #45610에서 다뤄진 문제로, 28.0.0 마일스톤에서 정리됐다. 클라우드 VPS에서 옆 기계가 같은 L2에 있는 경우는 흔치 않지만, docker version으로 엔진 버전이 28 이상인지 한 번 봐두는 게 좋다.
3. 기본값을 127.0.0.1로 바꿔둔다
매번 주소를 붙이는 걸 잊을까 걱정되면 Docker 데몬의 기본 바인딩 주소를 바꿀 수 있다. 여기서 함정이 하나 있다. 기본 bridge 네트워크와, compose가 프로젝트마다 만드는 "사용자 정의 bridge 네트워크"의 설정 위치가 다르다. /etc/docker/daemon.json에 둘 다 적어야 compose까지 적용된다.
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}
"ip"는 기본 bridge 네트워크용, default-network-opts는 사용자 정의 bridge 네트워크용이다. 바꾼 뒤에는 데몬을 재시작하고, 이미 만들어진 compose 네트워크는 docker compose down 후 다시 올려야 새 기본값을 받는다. 이 설정은 "주소를 안 적었을 때"의 기본값일 뿐이라, compose에 0.0.0.0:5432:5432라고 명시하면 그대로 모든 주소에 열린다.
4. 서버 바깥에 방화벽을 하나 더 둔다
ufw는 서버 안에서 도는 방화벽이라 Docker와 길이 엇갈렸다. 서버 바깥, 클라우드 쪽 방화벽은 사정이 다르다. AWS 보안 그룹, DigitalOcean Cloud Firewalls, Hetzner Firewall 같은 것들이다. DigitalOcean 문서는 이 차이를 이렇게 설명한다. iptables나 ufw 같은 호스트 방화벽은 서버마다 설정하는 것이고, Cloud Firewalls는 네트워크 계층에서 트래픽이 서버에 닿기 전에 막는다.
패킷이 서버에 도착하기 전에 걸러지니, 서버 안에서 Docker가 어떤 규칙을 만들든 상관없다. 22, 80, 443만 허용해두면 compose 파일에 실수로 5432:5432가 남아 있어도 바깥에서는 닿지 않는다. 보안 그룹 글에서 "잠금장치는 두 겹"이라고 했던 게 여기서도 통한다. 바인딩이 한 겹, 서버 바깥 방화벽이 한 겹이다.
5. 서버 안에서 걸러야 한다면 DOCKER-USER
특정 IP에서만 DB에 붙게 하고 싶은 경우처럼, 서버 안에서 Docker 트래픽을 걸러야 할 때도 있다. 이때 ufw나 FORWARD 체인 끝에 규칙을 덧붙이면 안 된다. Docker 문서는 FORWARD 끝에 붙인 규칙은 Docker 규칙 뒤에 처리된다고 적고 있다. 대신 Docker가 비워둔 DOCKER-USER 체인에 넣는다. Docker 규칙보다 먼저 처리되는 자리다.
# -I는 맨 위에 끼워 넣는다. 그래서 나중에 실행한 줄이 위로 간다.
# (2) 외부 인터페이스(eth0)로 서버 5432번에 들어온 패킷 중 198.51.100.7이 아니면 버린다
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 5432 ! -s 198.51.100.7 -j DROP
# (1) 이미 맺어진 연결의 응답은 먼저 통과시킨다
sudo iptables -I DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
여기서 헷갈리는 지점이 있다. DOCKER-USER에 도착한 패킷은 이미 PREROUTING에서 주소가 바뀐 뒤다. 그래서 --dport로 매칭하면 서버 포트가 아니라 컨테이너 안쪽 포트와 비교된다. 8080:80처럼 두 숫자가 다르면 --dport 8080은 아무것도 잡지 못한다. 위 예시에서 --ctorigdstport를 쓴 이유가 이것이다. 연결 추적(conntrack) 기록에 남은 "원래 받는 포트"로 비교하라는 뜻이고, Docker 문서도 같은 방법을 안내한다. 명령에 나온 eth0은 예시일 뿐이다. 요즘 VPS는 ens3, enp1s0 같은 이름을 쓰는 경우가 많으니 ip a로 외부 인터페이스 이름부터 확인하자. 그리고 이렇게 명령으로 넣은 iptables 규칙은 재부팅하면 사라진다. 저장 방법은 따로 챙겨야 한다.
한 가지 더. Docker 29.0.0부터 iptables 대신 nftables를 쓰는 백엔드가 실험 기능으로 들어왔는데, 이 백엔드에는 DOCKER-USER 체인이 없다. 켜면 기존 DOCKER-USER 규칙을 nftables 쪽으로 옮겨야 한다. 기본값은 여전히 iptables이니 직접 켜지 않았다면 신경 쓸 일은 없지만, 누군가 "nftables로 바꾸면 깔끔하다"며 설정을 바꿨다면 DOCKER-USER 규칙이 조용히 사라졌을 수 있다.

에이전트에게 맡길 때 생기는 일
에이전트가 compose 파일에 5432:5432를 넣는 건 이상한 일이 아니다. 로컬 노트북에서 개발할 때는 그게 편하고, 인터넷에 있는 예제 상당수가 그렇게 생겼다. 노트북에서는 공유기가 바깥과 막아주니 문제가 드러나지 않는다. 같은 파일이 공인 IP를 가진 VPS로 올라가는 순간 의미가 달라진다.
에이전트에게 서버 방화벽을 맡겨도 비슷한 일이 생긴다. "ufw로 22, 80, 443만 열어줘"는 정확하게 수행된다. 다만 그 지시만으로는 Docker가 만든 길이 막히지 않는다. 에이전트는 시킨 일을 했고, 빠진 건 질문 쪽이다.
그래서 이 규칙은 프로젝트 지침 파일(Claude Code의 CLAUDE.md, Codex의 AGENTS.md)에 한 줄로 적어두는 편이 낫다.
- docker-compose의 ports는 리버스 프록시(80/443)를 빼고 모두 "127.0.0.1:호스트:컨테이너" 형식으로 쓴다.
- DB, Redis 같은 내부 서비스에는 ports를 두지 않는다. 앱은 서비스 이름(db:5432)으로 접속한다.
같은 맥락에서 하나 덧붙인다. Claude Code가 제공하는 레퍼런스 개발 컨테이너에는 init-firewall.sh라는 iptables 스크립트가 들어 있다. 이 스크립트는 컨테이너 안에서 OUTPUT 기본 정책을 DROP으로 두고, 허용한 목적지로 나가는 연결만 통과시킨다. 에이전트가 아무 데나 요청을 보내지 못하게 하는 "나가는 쪽" 방화벽이다. 같은 iptables라도 어느 길목에 규칙을 걸었느냐에 따라 막는 대상이 완전히 달라진다. ufw가 Docker 트래픽을 놓치는 이유와 이 스크립트가 컨테이너의 외부 요청을 잡아내는 이유는 같은 데서 나온다. 규칙이 걸린 길목을 패킷이 지나가느냐, 그것 하나다.

ufw 앞을 지나지 않는 패킷
ufw status에 없는 포트가 열려 있는 건 ufw가 고장 나서가 아니다. Docker가 공개한 포트로 오는 패킷은 받는 주소가 컨테이너로 바뀌어 FORWARD 길로 가고, ufw가 지키는 INPUT 앞을 지나지 않는다.
그래서 막는 자리도 INPUT이 아니다. 공개 자체를 안 하거나(ports 삭제), 공개 범위를 서버 안으로 좁히거나(127.0.0.1:), 패킷이 서버에 닿기 전에 거르거나(클라우드 방화벽), Docker가 비워둔 자리에서 거른다(DOCKER-USER). 서버에 Docker를 깔았다면 "방화벽을 켰으니 괜찮다"는 판단은 한 번 의심해볼 일이다. 확인은 언제나 바깥에서, 내 노트북의 nc 한 줄로 하면 된다.