Traefik v3 Docker Compose로 5개 앱 하나로 묶기
Traefik v3를 사용하여 하나의 IP에서 5개의 앱을 운영하는 방법을 설명합니다. Host rule 기반 라우팅과 Let's Encrypt 자동 설정법을 다루며, acme.json 파일 권한 문제로 인한 startup 오류 해결책도 포함합니다.
하나의 IP, 5개의 앱, 하나의 port 443
VPS에는 하나의 공용 IPv4 주소와 하나의 TCP port 443이 있습니다. 하나의 서버에서 Gitea, 앱의 스테이징 복사본, 내부 대시보드, 상태 페이지, webhook 수신기를 운영해야 합니다. 즉, 5개의 hostname을 하나의 서버에서 처리해야 합니다. 리버스 프록시는 :80 및 :443을 점유하며, 모든 요청의 Host 헤더를 읽어 적절한 컨테이너로 전달하는 역할을 합니다. Traefik은 이 기능을 수행하며, 사용자가 직접 certbot을 실행하지 않아도 각 hostname에 대한 인증서를 발급하고 갱신합니다.
Traefik이 nginx server {} 블록과 다른 점은 설정 방식입니다. nginx는 파일을 수정하고 reload해야 하며, 인증서 관리는 별도의 작업입니다. 이는 nginx에서 certbot으로 Let's Encrypt 인증서를 발급하는 방식과 같으며, 갱신 타이머가 웹 서버 외부에서 작동합니다. Traefik의 Docker provider는 Docker event stream을 감시하며 컨테이너의 labels를 읽습니다. Host() 규칙 label이 포함된 컨테이너를 시작하면 1초 이내에 라우팅이 가능해지며, 컨테이너를 중지하면 라우트도 사라집니다. 이것이 주의할 점입니다. label에 포함된 설정은 여러 곳에 분산되어 존재하며, 잘못된 label은 오류를 발생시키지 않습니다. 컨테이너가 단순히 라우팅되지 않을 뿐, Traefik은 아무런 메시지도 출력하지 않습니다.
네 가지 명사
- Entrypoints는 리스닝 소켓입니다.
:80의web와:443의websecure두 개를 정의해야 합니다. - Routers는 요청(
Host(...))을 매칭하여 서비스에 연결합니다. 인증서는tls.certresolver를 통해 라우터별로 요청됩니다. - Services는 백엔드입니다. Docker 네트워크 내부의 컨테이너와 해당 컨테이너가 리스닝하는 포트를 의미합니다.
- Middlewares는 라우터와 서비스 사이에 위치합니다. 기본 인증(basic auth), IP 허용 목록, 헤더 재작성, 리다이렉트 등이 있습니다.
정적 설정(entrypoints, providers, ACME)은 Traefik의 커맨드 라인이나 traefik.yml를 통해 전달됩니다. 정적 설정을 변경하려면 Traefik를 재시작해야 합니다. 동적 설정(routers, services, middlewares)은 컨테이너 label을 통해 전달되며 핫 리로드(hot-reloaded)됩니다. 이 두 설정을 혼동하는 것이 "플래그가 작동하지 않는" 일반적인 원인입니다.
The compose file
proxy라는 이름의 공유 Docker network가 핵심입니다. Traefik은 두 서비스가 모두 해당 network에 연결되어 있어야만 컨테이너에 도달할 수 있습니다.
name: edge
networks:
proxy:
name: proxy
services:
traefik:
image: traefik:v3.5
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.network=proxy
- --entryPoints.web.address=:80
- --entryPoints.websecure.address=:443
- --entryPoints.web.http.redirections.entryPoint.to=websecure
- --entryPoints.web.http.redirections.entryPoint.scheme=https
- --certificatesresolvers.le.acme.email=you@example.com
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.tlschallenge=true
# while you iterate, point at staging so a mistake costs nothing:
# - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
- --api.dashboard=true
- --log.level=INFO
- --accesslog=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
- traefik.http.routers.dashboard.entrypoints=websecure
- traefik.http.routers.dashboard.tls.certresolver=le
- traefik.http.routers.dashboard.service=api@internal
- traefik.http.routers.dashboard.middlewares=dashboard-auth
- traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$REPLACE$$THIS
gitea:
image: gitea/gitea:1 # major-only pin keeps this demo copy-pasteable; pin an exact release in production
restart: unless-stopped
volumes:
- ./gitea:/data
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.gitea.rule=Host(`git.example.com`)
- traefik.http.routers.gitea.entrypoints=websecure
- traefik.http.routers.gitea.tls.certresolver=le
- traefik.http.services.gitea.loadbalancer.server.port=3000docker compose up -d를 설정한 다음 docker compose logs -f traefik를 설정합니다. 추가되는 각 app은 gitea 블록을 복사하여 사용하며, 고유한 router name, Host(), 그리고 내부 port를 가집니다. Nextcloud install running in Docker with TLS and backups도 동일한 방식으로 추가할 수 있습니다. published ports를 제거하고 proxy에 연결한 뒤, router labels를 통해 hostname과 certificate를 처리하도록 설정하면 됩니다.
주의해야 할 다섯 가지 세부 사항이 있습니다.
exposedByDefault=false가 설정되지 않으면 컨테이너는 traefik.enable=true를 갖기 전까지 Traefik에 노출되지 않습니다. 이 설정을 누락하면 테스트용으로 실행한 postgres를 포함하여 실행되는 모든 컨테이너에 대해 route가 생성됩니다.
providers.docker.network=proxy는 컨테이너가 여러 network에 연결되어 있을 때 Traefik이 어떤 network를 사용할지 지정합니다. 이 설정을 생략하면 Traefik이 잘못된 컨테이너 IP를 선택할 수 있으며, 이는 애플리케이션 오류처럼 보이는 502 에러로 나타납니다.
loadbalancer.server.port=3000는 컨테이너 내부의 port입니다. Gitea는 내부적으로 3000번 port를 사용합니다. 모든 app 컨테이너는 port를 publish하지 않으며, 오직 Traefik만 port를 publish한다는 점에 유의하십시오.
web entrypoint의 redirect 설정은 plaintext 요청을 HTTPS로 308 redirect합니다. 그럼에도 80번 port는 열려 있어야 합니다. ACME HTTP challenge와 hostname만 입력하는 사용자를 위해 필요하기 때문입니다.
basic-auth hash에 나타나는 중복된 $$은 Compose의 escaping 방식이며 오타가 아닙니다. htpasswd -nbB admin 'your-password'(apache2-utils package)를 사용하여 해시를 생성한 후, 모든 $를 두 번 입력하십시오.
인증서와 acme.json의 함정
tlschallenge=true는 TLS-ALPN-01 방식을 선택합니다. Let's Encrypt가 443 포트로 서버에 접속하면, Traefik이 TLS 핸드셰이크 내부에서 챌린지에 응답합니다. 대안은 80 포트를 사용하는 HTTP-01 방식입니다. Traefik의 command: 목록에서 tlschallenge 라인을 다음 두 줄로 교체하십시오.
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web두 방식 모두 작동합니다. 두 방식 모두 호스트네임에 대한 공용 DNS가 이미 VPS를 가리키고 있어야 합니다. 인증 기관이 이름을 확인하고 외부에서 접속하기 때문입니다. 먼저 A (및 AAAA) 레코드를 생성하고, dig +short git.example.com로 확인한 후 Traefik을 시작하십시오.
다음은 많은 사용자가 시간을 허비하게 만드는 함정입니다. Traefik은 ACME 계정 키와 모든 인증서를 하나의 acme.json 파일에 저장합니다. 해당 파일이 그룹 또는 기타 사용자에게 읽기 권한이 있으면, Traefik은 다음과 유사한 메시지를 출력하고 중단됩니다.
error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600해결 방법은 위에 설명한 방식입니다. 디렉토리를 bind-mount하여 Traefik이 적절한 권한 모드로 파일을 직접 생성하도록 하십시오. 만약 touch을 사용하여 acme.json을 생성했다면, umask 설정으로 인해 권한이 644로 설정되었을 것입니다. 호스트에서 다음 명령어로 권한을 수정하십시오.
chmod 600 ./letsencrypt/acme.json
docker compose restart traefik해당 디렉토리를 애플리케이션 볼륨과 함께 백업하십시오. 파일을 잃어버려도 인증서를 재발급하면 되므로 치명적이지는 않습니다. 하지만 5개의 호스트네임을 동시에 재발급하면 rate limit에 걸리게 됩니다.
반복 작업 중에는 staging CA를 사용하십시오. caserver 라인의 주석을 해제하여 모든 라우트를 정상 작동시킨 후, 다시 주석을 처리하고 acme.json을 삭제하여 운영 인증서를 새로 요청하십시오. Let's Encrypt 운영 환경은 동일한 호스트네임 세트에 대해 주당 5개의 중복 인증서 발급만 허용하며, 동일한 이름에 대한 반복적인 검증 실패를 제한합니다. Staging 환경은 신뢰할 수 없는 인증서를 발급하며, 브라우저 경고가 뜨는 것이 작업이 성공했다는 신호입니다. Staging은 제한이 훨씬 완만합니다.
대시보드는 데모가 아닌 제어 인터페이스입니다
대부분의 퀵스타트 가이드는 --api.insecure=true를 설정합니다. 이 설정은 인증 없이 8080 포트로 대시보드를 서비스합니다. 공인 IP를 가진 서버에서 이 설정을 사용하면, 스캔을 수행하는 모든 사용자에게 라우팅 토폴로지, 호스트 이름, 미들웨어 이름 및 백엔드 포트를 노출하게 됩니다.
위의 traefik 서비스 레이블은 대안을 제시합니다. 대시보드를 다른 앱과 동일하게 실제 호스트 이름을 통해 TLS로 라우팅하고, basicauth 뒤에 배치합니다. service=api@internal은 라우터를 Traefik의 내장 API에 연결하는 역할을 합니다. IP 허용 목록(allow-list)을 왼쪽에서 오른쪽 방향으로 체이닝하여 보안을 더욱 강화하십시오. 사무실 주소가 유동 IP라면, 범위를 동일한 VPS에서 직접 호스팅하는 WireGuard VPN이 할당하는 서브넷으로 설정하십시오. 이렇게 하면 터널을 통해서만 대시보드에 접속할 수 있습니다.
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authThe Docker socket is root
/var/run/docker.sock는 host의 /를 mount하는 container를 생성할 수 있는 API입니다. 이 API에 대한 접근 권한은 해당 machine의 root 권한과 동일합니다. Traefik은 label을 읽기 위해 이 권한이 필요합니다.
mount 시 :ro를 유지하되, 그 효과를 명확히 인지해야 합니다. 이 설정은 socket file을 read-only로 만듭니다. 하지만 socket을 통한 Docker API로의 POST 요청 자체를 차단하지는 않습니다. 가장 확실한 완화 방법은 Traefik에 socket을 직접 전달하지 않고, 중간에 filtering proxy를 배치하는 것입니다:
dockerproxy:
image: tecnativa/docker-socket-proxy # pin the current tag
restart: unless-stopped
environment:
CONTAINERS: 1
NETWORKS: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxyTraefik에서 socket volume을 제거하고 provider를 proxy로 지정하십시오:
--providers.docker.endpoint=tcp://dockerproxy:2375이렇게 하면 Traefik은 container와 network에 대한 read access는 유지하지만, 새로운 요소를 생성하는 권한은 상실합니다.
방화벽, 포트, 그리고 모두가 잘못 알고 있는 규칙
두 개의 포트가 열려 있으며, SSH도 포함됩니다:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableDocker의 published ports는 ufw를 우회합니다. Docker는 ufw의 chains보다 먼저 평가되는 자체 iptables rules를 삽입합니다. 따라서 ufw에서 deny 설정이 되어 있더라도 ports: ["3000:3000"]로 시작된 container는 인터넷에서 접속이 가능합니다. 이는 방화벽 설정의 문제가 아니라 구조적인 문제입니다. Traefik에서만 ports를 publish하십시오. 다른 모든 container에는 networks: [proxy]만 부여하십시오. 호스트에 반드시 접속해야 하는 경우가 있다면 loopback에 bind하십시오 — "127.0.0.1:3000:3000".
Troubleshooting: errors you will actually see
404 page not found (Traefik 응답). 일치하는 router가 없습니다. 발생 가능성이 높은 순서는 다음과 같습니다: container에 traefik.enable=true가 없거나 (exposedByDefault=false 미설정); Host() rule이 입력한 이름과 일치하지 않음; 한 label의 router name과 다른 label의 router name이 서로 다름 (routers.gitea.rule와 routers.gitea.entrypoints는 동일한 단어여야 함); 또는 hostname을 backtick 대신 따옴표로 감쌈. Traefik v3의 matcher 내부에는 backtick을 사용해야 합니다.
502 Bad Gateway. router가 일치했으나 backend에 접속할 수 없습니다. 대부분 container가 proxy network에 연결되어 있지 않은 경우입니다. docker inspect -f '{{json .NetworkSettings.Networks}}' gitea을 확인하십시오. 다른 원인은 잘못된 loadbalancer.server.port입니다. 공개된 port를 입력했거나, app이 다른 곳에서 대기 중일 수 있습니다. 로그에 시도 내용이 기록됩니다: dial tcp 172.18.0.5:8080: connect: connection refused.
브라우저 경고 발생 및 TRAEFIK DEFAULT CERT로 인증서 발급. 해당 hostname에 대한 인증서가 없어 Traefik이 self-signed placeholder를 제공했습니다. ACME 로그를 확인하십시오:
unable to obtain ACME certificate for domains "git.example.com" ...
acme: error: 400 ... DNS problem: NXDOMAIN looking up A for git.example.comDNS가 아직 해당 서버를 가리키지 않고 있습니다. 레코드를 수정하고, TTL이 만료될 때까지 기다린 후 Traefik을 재시작하십시오.
HTTP challenge에서 Invalid response from http://git.example.com/.well-known/acme-challenge/... 발생: 외부에서 port 80을 통해 Traefik에 접속할 수 없습니다. 보통 ufw가 아닌 VPS 앞단의 provider-level firewall 문제입니다.
인증서가 발급되지 않으며, Cloudflare의 DNS 설정에 orange cloud가 켜져 있음. Cloudflare가 edge에서 TLS를 종료하므로 TLS-ALPN-01을 완료할 수 없습니다. 인증서 발급 중에는 레코드를 DNS-only로 설정하거나, API token을 사용하는 DNS-01 challenge로 전환하십시오. DNS-01은 wildcard 인증서를 발급할 수 있는 유일한 challenge입니다.
Redirect loop. Traefik 앞단의 설정이 이미 TLS를 종료하고 :80으로 plaintext를 전달하고 있습니다. 이로 인해 entrypoint redirect가 다시 HTTPS로 요청을 보냅니다. 두 개의 redirect 중 하나를 제거하십시오.
Keeping it running
Docker의 unit은 boot-enabled(systemctl is-enabled docker) 상태여야 합니다. restart: unless-stopped은 재부팅 후 stack을 다시 실행합니다. 명시적인 제어가 필요하다면, RemainAfterExit=yes 플래그와 함께 docker compose -f /srv/edge/compose.yml up -d을 실행하는 작은 systemd unit을 사용하여 systemctl status edge 및 ordering 제어를 수행할 수 있습니다.
Traefik 태그를 고정하십시오(traefik:v3.5을 사용하고 latest은 사용하지 마십시오). v2에서 v3로 업그레이드되면서 rule syntax와 provider name이 변경되었습니다. 자동화된 latest은 더 이상 인식할 수 없는 설정을 로드하려고 시도합니다. 의도적으로 업그레이드하십시오. migration notes를 읽고, 태그를 변경한 후, docker compose up -d traefik을 실행하고, 로그를 확인하십시오. 여전히 v2 태그를 사용 중이라면, the Traefik v2 to v3 migration guide에서 모든 이름 변경 사항, compatibility mode, 그리고 인증서를 유지하는 rollback 방법을 확인할 수 있습니다.
./letsencrypt과 각 app의 data volume을 백업하십시오. Traefik은 compose file을 통해 재구축할 수 없는 다른 state를 보유하지 않습니다.
확장 시 발생하는 문제점
첫 번째 한계는 처리량이 아니라 단일 서버입니다. 하나의 VPS에서 실행되는 하나의 Traefik은 5개의 앱에 대해 단일 장애 지점(single point of failure)이 됩니다. 또한 acme.json은 flat-file 저장 방식을 사용하므로, 두 개의 Traefik 인스턴스가 동시에 쓰기를 수행하면 파일이 손상됩니다. 확장을 위해서는 인증서 저장소를 파일 외부로 옮기거나, 다른 곳에서 TLS를 종료해야 합니다.
두 번째는 장시간 유지되는 연결입니다. Server-sent events, 대용량 업로드, 그리고 느린 클라이언트는 entrypoint의 응답 타임아웃에 걸리게 됩니다. --entryPoints.websecure.transport.respondingTimeouts.readTimeout과 그 형제인 writeTimeout, idleTimeout이 이 설정을 조절하는 옵션입니다. WebSockets는 추가 설정 없이 통과됩니다.
세 번째는 디스크입니다. --accesslog=true은 stdout에 로그를 기록하며, Docker의 json-file 드라이버는 용량 제한을 설정하지 않으면 해당 로그를 영구적으로 보관합니다. Traefik 서비스에 logging.options.max-size을 설정하거나, access log를 파일로 작성한 뒤 rotation을 수행하십시오.
이 모든 과정에 오케스트레이터가 필요하지는 않습니다. 다만 고정 IP를 가지고 80 및 443 포트가 외부로 개방된, 관리 가능한 서버가 필요합니다. 작은 VPS 하나면 모든 의존성이 충족됩니다.
FAQ
Traefik를 실행 중인데도 Certbot이 필요한가요?
필요하지 않습니다. Traefik의 ACME resolver는 라우팅되는 모든 hostname에 대해 인증서를 요청하고 갱신하며, 이를 acme.json에 저장합니다. nginx 또는 다른 서버가 직접 TLS를 종료하는 경우에는 Certbot이 적합한 도구입니다. 동일한 hostname에 대해 두 도구를 모두 실행하면 Let's Encrypt의 rate limit만 소모하게 됩니다.
Traefik를 통해 컨테이너에서 404 오류가 발생하는 이유는 무엇인가요?
Traefik가 404를 반환하는 것은 요청과 일치하는 router가 없음을 의미합니다. 컨테이너에 traefik.enable=true이 설정되어 있는지(exposedByDefault=false 설정 시 필수), Host() 값이 입력한 이름과 일치하는지, 그리고 해당 app의 모든 label에서 router name이 동일한지 확인하십시오. Traefik v3의 matcher 내부에는 따옴표가 아닌 backtick을 사용해야 합니다.
여기서 404와 502의 차이점은 무엇인가요?
404는 라우팅이 수행되지 않았음을 의미하며, 502는 router가 일치했으나 backend가 연결을 거부했음을 의미합니다. 일반적인 502 발생 원인은 컨테이너가 proxy network에 연결되지 않았거나, loadbalancer.server.port이 app이 컨테이너 내부에서 리스닝하는 포트가 아닌 공개된(published) 포트를 가리키는 경우입니다. access log를 통해 Traefik가 접속을 시도한 정확한 주소를 확인할 수 있습니다.
Docker socket을 read-only로 마운트하는 것만으로 충분한가요?
:ro flag는 socket file을 read-only로 만들지만, 그 뒤의 API까지 제한하지는 않습니다. POST 요청은 여전히 해당 socket을 통해 전달되며, Docker API 접근 권한은 호스트의 root 권한과 동일합니다. 더 강력한 설정은 위에 설명된 docker-socket-proxy 컨테이너를 사용하는 것입니다. 이 방식은 Traefik에 컨테이너 및 network 읽기 권한만 부여하고 쓰기 작업은 완전히 차단합니다.
Traefik가 wildcard certificate를 발급할 수 있나요?
DNS provider의 API token을 사용하는 DNS-01 challenge를 통해서만 가능합니다. TLS-ALPN-01 및 HTTP-01은 단일 hostname만 검증하므로 wildcard를 생성할 수 없습니다. Cloudflare와 같은 CDN이 VPS 앞에서 TLS를 종료하여 다른 두 가지 challenge가 실패하는 경우, DNS-01이 해결책입니다.