~/yazilar/traefik-ile-reverse-proxy-ve-otomatik-https
Traefik ile reverse proxy ve otomatik HTTPS
içindekiler
Serinin buraya kadarki kısmında uygulamayı hep 127.0.0.1:8000 üzerinde tuttum ve dışarı açmadım. Şimdi onu bir alan adından, geçerli bir sertifikayla ve 80/443 dışında hiçbir port açmadan yayınlayacağım. Bunu yapan şey bir reverse proxy: dışarıdan gelen bütün istekleri tek noktadan karşılayıp, alan adına ve yola göre doğru container'a yönlendiren bir ara katman.
Traefik'i seçmemin sebebi Docker'la konuşabilmesi. Yeni bir container başlattığında, onun label'larını okuyup yönlendirmeyi kendisi kuruyor. nginx'te her yeni servis için config dosyası yazıp proxy'yi yeniden yüklemek gerekiyor, Traefik'te yeni servisin Compose tanımına birkaç label eklemek yetiyor.
Sürüm uyarısı en baştan: Traefik'in büyük sürümleri arasında hem label hem de yapılandırma dosyasının sözdizimi değişiyor. Aşağıdaki örnekler belirli bir büyük sürüme göre yazıldı. Başka bir sürüm kullanıyorsan label adları ve config anahtarları farklı olabilir, kopyalamadan önce kendi sürümünün dokümantasyonuna bak.
İki tür yapılandırma
Traefik'te yapılandırma ikiye ayrılıyor ve bunu karıştırmak en sık yaşanan sorun. Statik yapılandırma, Traefik açılırken bir kere okunuyor: hangi portları dinleyeceği (entrypoint'ler), Docker'ı nereden dinleyeceği, sertifikaları nasıl alacağı. Bunu değiştirince Traefik'i yeniden başlatmak gerekiyor. Dinamik yapılandırma ise çalışırken değişebilen kısım: hangi alan adı hangi servise gidiyor. Bunu Docker label'larından alıyor ve yeni container gelince anında uyguluyor.
Statik kısmı ben traefik.yml dosyasında tutuyorum. Her şeyi komut satırı bayraklarına yazmak da mümkün ama uzadıkça okunması zorlaşıyor.
Statik yapılandırma
Önce bir ağ ve sertifikaların saklanacağı dosya:
docker network create web
mkdir -p ./traefik/letsencrypt
touch ./traefik/letsencrypt/acme.json
chmod 600 ./traefik/letsencrypt/acme.jsonacme.json içinde özel anahtarlar duracak. İzni 600 değilse Traefik güvenlik gerekçesiyle dosyayı kullanmayı reddediyor:
error: unable to open the file ...acme.json: permissions 644 for ...acme.json are too open, please use 600traefik/traefik.yml:
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: web
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
api:
dashboard: trueİki entrypoint var: web (80) ve websecure (443). web entrypoint'indeki redirections bütün HTTP trafiğini HTTPS'e çeviriyor. Bunu burada, entrypoint seviyesinde yaptığım için her servis için ayrı ayrı yönlendirme kuralı yazmama gerek kalmıyor. Düz HTTP ile gelen herkes 443'e gidiyor.
providers.docker altında exposedByDefault: false çok önemli. Varsayılan true olsaydı Traefik, makinedeki her container'ı otomatik olarak yayınlamaya çalışırdı. false ile sadece traefik.enable=true label'ı olan container'lar yayınlanıyor. Veritabanının yanlışlıkla internete açılması bu satırla engelleniyor.
certificatesResolvers kısmı Let's Encrypt'i kuruyor. httpChallenge yöntemi, Let's Encrypt'in alan adının gerçekten bu sunucuya ait olduğunu doğrulamak için 80 portu üzerinden bir dosya istemesine dayanıyor. Bu yüzden 80 portunun dışarıdan erişilebilir olması ve alan adının bu sunucunun IP'sine çözülmesi şart. email alanına örnek olarak admin@example.com yazdım, kendi adresinle değiştir. Let's Encrypt süre sonu uyarı e-postalarını 2025'te bıraktı, yani yenilemenin aksadığını bu adresten öğrenemezsin. Sertifika bitiş tarihini izleme sistemine eklemek daha sağlam.
certificatesResolvers:
letsencrypt:
acme:
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
# email, storage ve httpChallenge yukarıdaki gibiTraefik servisi
Traefik'in kendisi de bir container. Compose dosyasına ekliyorum:
traefik:
image: traefik:3.1
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- webBurada serinin başından beri uyardığım şeyi bilerek yapıyorum: docker.sock'u container'a bağlıyorum. Traefik container'ları keşfetmek için buna ihtiyaç duyuyor. :ro eklediğimi görüyorsun ama güvenlik yazısında yazdığım gibi bu neredeyse hiçbir şey değiştirmiyor: bir Unix soketine API isteği göndermek yazma işlemi sayılmadığı için, salt okunur bağlanmış soket üzerinden de her komut gönderilebiliyor. Yani Traefik ele geçirilirse saldırgan daemon'a komut verip host'a çıkabilir.
Traefik internete bakan servis olduğu için bu risk ciddi. İki şekilde daraltıyorum.
Birincisi, soketi doğrudan vermek yerine araya bir socket proxy koymak. Bu küçük container soketi tek başına görüyor, Traefik ise ona bağlanıyor ve proxy sadece okuma amaçlı uçlara (container ve servis listeleme) izin verip container oluşturma gibi tehlikeli uçları reddediyor:
dockerproxy:
image: tecnativa/docker-socket-proxy:0.2.0
restart: unless-stopped
environment:
CONTAINERS: 1
NETWORKS: 1
SERVICES: 1
TASKS: 1
POST: 0 # yazma/oluşturma uçları kapalı
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
traefik:
image: traefik:3.1
# ... yukarıdaki gibi, ama docker.sock mount'u YOK
networks:
- web
- internalBu durumda statik config'teki Docker provider'ını soket yerine proxy'ye yönlendiriyorsun:
providers:
docker:
endpoint: "tcp://dockerproxy:2375"
exposedByDefault: false
network: webPOST: 0 burada kilit satır: proxy'ye yazma isteklerini reddetmesini söylüyor. Böylece Traefik container'ları görebiliyor ama yenisini oluşturamıyor. Traefik ele geçirilse bile proxy üzerinden yapabileceği şey okumayla sınırlı kalıyor.
Okuma da masum sayılmaz. CONTAINERS: 1 ile açılan uçlar, container'ların ortam değişkenlerini de döndürüyor. POSTGRES_PASSWORD gibi değerleri ortam değişkeniyle verdiysen Traefik'i ele geçiren onları da okur. Bu yüzden güvenlik yazısındaki secret dosyası (_FILE) yöntemi burada da önemli.
Compose dosyasının en altında ağları tanımlamayı unutma. web ağını yukarıda docker network create ile elle oluşturdum, Compose'a onu yeniden yaratmamasını external ile söylüyorum. Compose kendi oluşturduğu ağların adına proje adını eklediği için (uygulama_web gibi), Traefik'in network: web ayarı ancak böyle tutuyor:
networks:
web:
external: true
internal:
internal: true # sadece Traefik ile socket proxy arasında
backend:
internal: trueBu tanım olmazsa docker compose up "refers to undefined network" hatasıyla duruyor.
İkincisi, mümkünse Traefik'i rootless Docker üstünde çalıştırmak. O zaman soket zaten root'a değil, normal bir kullanıcıya ait oluyor ve olası bir kaçışın bedeli düşüyor. İkisini birden yaparsan en iyisi.
Uygulamayı yayınlamak
Artık web servisine Traefik label'ları ekliyorum. Önceki yazılardaki ports: 127.0.0.1:8000:8000 satırı tamamen kalkıyor, çünkü dışarıya artık Traefik bakıyor, uygulamaya doğrudan erişen yok:
web:
image: uygulama-web:1.0.0
restart: unless-stopped
# ports satırı yok: Traefik üzerinden erişiliyor
networks:
- web
- backend
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`uygulama.example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=letsencrypt"
- "traefik.http.services.web.loadbalancer.server.port=8000"Label'ların mantığı şöyle. traefik.enable=true olmadan Traefik bu container'a hiç bakmıyor (çünkü exposedByDefault: false). routers.web.rule hangi isteğin bu servise gideceğini söylüyor: Host başlığı uygulama.example.com olanlar. entrypoints=websecure sadece 443'ü dinle demek. tls.certresolver=letsencrypt ise bu alan adı için otomatik sertifika al demek. Son satır Traefik'e container'ın hangi portta dinlediğini söylüyor: bu port host'ta yayınlanmıyor, sadece Traefik'in container'a web ağı üzerinden ulaşırken kullandığı iç port.
routers.web.rule içindeki alan adını ters tırnakla yazıyorum. Kural sözdizimi tek tırnağı kabul etmiyor, kaçışlı çift tırnak çalışıyor ama YAML içinde okunması zorlaşıyor. Değerin içinde ters tırnak olduğu için bütün label'ı çift tırnağa aldım.
Uygulamanın hem web hem backend ağında olduğuna dikkat et. web üzerinden Traefik'e, backend üzerinden veritabanına bağlanıyor. Veritabanı ve Redis ise web ağında değil, yani Traefik onları görmüyor bile.
Kaldır ve bak:
docker compose up -d
docker compose logs -f traefikİlk açılışta Traefik'in sertifika aldığını loglardan görüyorsun. DNS doğru çözülüyor ve 80 portu dışarı açıksa birkaç saniye içinde sertifika geliyor:
curl -sI https://uygulama.example.com | head -3
curl -sI http://uygulama.example.com | grep -i locationHTTP/2 200
...
location: https://uygulama.example.com/İkinci komut HTTP'den HTTPS'e yönlendirmenin çalıştığını gösteriyor: 80'e gelen istek 443'e yönleniyor.
Dashboard'u açıkta bırakma
Statik config'te api.dashboard: true dedim ama dashboard'a giden bir router tanımlamadım, yani şu an dışarıdan erişilemiyor. Bir çok örnekte dashboard'u api.insecure: true ile ya da kimlik doğrulaması olmadan bir alan adına bağlanmış görürsün. Bu, çalışan bütün servislerini, ağ yapını ve router kurallarını isteyen herkese göstermek demek. Açmaya değmez.
Açman gerekiyorsa en azından iki şeyle koru: sadece HTTPS üzerinden ver ve önüne basic auth koy. Traefik'in kendi container'ına eklenen label'larla:
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=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=${DASHBOARD_AUTH}"service=api@internal Traefik'in kendi dahili API servisi, dashboard'u o sunuyor. basicauth middleware'i de önüne parola koyuyor. Kullanıcı ve parola hash'ini htpasswd ile üretip .env içine DASHBOARD_AUTH olarak koyuyorum. -b ile parolayı komut satırında vermek yerine sorulmasını bekliyorum, böylece shell geçmişine düşmüyor:
htpasswd -nB adminadmin:$2y$05$............................................Label'ın içinde kullanacaksan dikkat edilecek bir nokta var: Compose, label metnindeki tek $ işaretlerini değişken genişletmesi sanıyor. Hash'i doğrudan label'a yazarsan $ işaretlerini $$ yapman gerekiyor. Ben hash'i .env dosyasına koyup label'da ${DASHBOARD_AUTH} ile çağırıyorum. Burada da bir ayrıntı var: .env içinde tırnaksız ya da çift tırnaklı değerlerdeki $ işaretleri de genişletiliyor, tek tırnaklı değerler ise olduğu gibi okunuyor. O yüzden satırı tek tırnakla yazıyorum:
DASHBOARD_AUTH='admin:$2y$05$............................................'Dashboard, kampüs içinden erişilen bir şeyse basic auth yerine IP allowlist middleware'i ya da doğrudan sadece iç ağdan erişim daha da iyi.
Sertifika gelmezse, 404 ya da 502 alırsan
Sertifika gelmiyorsa sebep neredeyse her zaman challenge'ın başarısız olması. Loglarda şuna benzer bir satır arıyorsun:
... unable to obtain ACME certificate for domains "uygulama.example.com": ... Kontrol sırası: alan adı gerçekten bu sunucunun IP'sine mi çözülüyor (dig +short uygulama.example.com), 80 portu dışarıdan erişilebilir mi (httpChallenge bunu kullanıyor), ve acme.json izni 600 mü. En sık sebep 80'in bulut güvenlik grubunda ya da kurumun dış güvenlik duvarında kapalı olması. UFW'ye bakmak burada pek işe yaramaz: Docker yayınladığı portlar için kendi kurallarını yazdığından 80 ve 443 UFW'de kapalı görünse de açık olabiliyor.
404 alıyorsan ama Traefik ayakta ise, genelde router ile servis farklı ağlarda ya da exposedByDefault: false iken traefik.enable=true label'ı unutulmuş oluyor. Traefik hangi router ve servisleri gördüğünü dashboard'da gösteriyor. Aynı bilgiyi API'den de alabilirsin. Bu kurulumda API sadece dashboard router'ı üzerinden, basic auth arkasında erişilebilir (api.insecure kapalı olduğu için 8080 açık değil):
curl -su admin https://traefik.example.com/api/http/routers | jq -r '.[].name'Bad Gateway (502) alıyorsan Traefik container'a ulaşıyor ama bağlantı reddediliyor. Ya loadbalancer.server.port yanlış ya da uygulama container içinde sadece 127.0.0.1 dinliyor. Başka bir container'dan gelen bağlantıyı kabul etmesi için 0.0.0.0 dinlemesi gerekiyor. Gateway Timeout (504) ise daha çok iki container'ın ortak bir ağda olmamasından ya da providers.docker.network ayarının yanlış ağı göstermesinden çıkıyor.
Yeni bir servis eklemek artık Compose dosyasına birkaç label yazmaktan ibaret.
Sıradaki yazı: k3s ile tek düğümlü bir Kubernetes kümesi