Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save fatihyildizhan/8f124039a9bd3801f0caf3c01c3601fb to your computer and use it in GitHub Desktop.

Select an option

Save fatihyildizhan/8f124039a9bd3801f0caf3c01c3601fb to your computer and use it in GitHub Desktop.
Traefik v3 + v2 + v1 + Docker + Let's Encrypt + GitHub Container Registry ghcr.io + Updated on 4 September 2026
Traefik v3 + v2 + v1 + Docker + Let's Encrypt + GitHub Container Registry ghcr.io + Updated on 4 September 2026
Content:
- Ubuntu 26.04 LTS / 24.04 LTS
- Docker Engine 29.8.0
- Docker Compose v5.5.1 (docker compose plugin)
- Traefik v3.7 with httpChallenge / tlsChallenge / dnsChallenge <-- USE THIS FOR NEW SETUPS
- Traefik v2.11 with httpChallenge <-- legacy, security fixes only
- Traefik v1.7 with dnsChallenge <-- EOL since 2019, keep for old servers
- GitHub Container Registry ghcr.io
- Let's Encrypt
Which version should I pick?
- v3 -> Every new server. Actively developed, HTTP/3, simpler config, one router per service instead of two.
- v2 -> Only if you already run v2 in production and cannot migrate today. Still gets security fixes, no new features.
- v1 -> End of life since 2019, no fixes at all. Kept here only so old servers stay documented. Do not start new projects with it.
TL DR:
- Line 28 -> 0. Common setup: server, Docker, network. Do this first for every version.
- Line 76 -> 1. Traefik v3 configs (recommended)
- Line 306 -> 2. Traefik v2 configs (legacy)
- Line 424 -> 3. Traefik v1 configs (EOL)
- Line 541 -> 4. ghcr.io login, first container, troubleshooting
- Line 582 -> 5. v2 -> v3 migration checklist
================================================================================
0. COMMON SETUP (do this once, for any Traefik version)
================================================================================
1. Create an Ubuntu 26.04 LTS (or 24.04 LTS) droplet / VM
1.1. A Docker pre-installed image works too, then skip step 4.
2. Point your domain to the server -> yourdomain.com
2.1. Create an A record -> @ -> server IPv4
2.2. Create an AAAA record -> @ -> server IPv6 (optional but recommended)
2.3. Create a wildcard A record -> * -> server IPv4 if you want subdomains without touching DNS again
3. Connect over SSH -> ssh root@server-ip
4. Install Docker Engine from the official repository
4.1. sudo apt-get update
4.2. sudo apt-get install -y ca-certificates curl
4.3. sudo install -m 0755 -d /etc/apt/keyrings
4.4. sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
4.5. sudo chmod a+r /etc/apt/keyrings/docker.asc
4.6. echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
4.7. sudo apt-get update
4.8. sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
4.9. sudo systemctl status docker
NOTE: apt-key is deprecated and removed on modern Ubuntu. Use the keyring method above, not "curl ... | apt-key add -".
5. Check the versions
5.1. docker --version -> Docker version 29.8.0
5.2. docker compose version -> Docker Compose version v5.5.1
NOTE: "docker compose" (plugin, v2+) replaced the old "docker-compose" python script. You do not need to install
docker-compose separately any more. If a script still calls "docker-compose", install the alias package:
sudo apt-get install -y docker-compose-v2
6. Run your first container to confirm Docker works
6.1. docker run --rm ghcr.io/doxa-labs/docker-helloworld/docker-helloworld:latest
7. Create the shared proxy network. Traefik and every published container must sit on it.
7.1. docker network create proxy
7.2. docker network ls
NOTE: the network name is up to you (proxy, external, traefik...). Keep it identical in every compose file below.
8. Create the Traefik folder and files
8.1. sudo mkdir -p /opt/traefik && cd /opt/traefik
8.2. sudo touch /opt/traefik/acme.json
8.3. sudo chmod 600 /opt/traefik/acme.json
8.4. sudo mkdir -p /opt/traefik/logs
NOTE: acme.json MUST exist and MUST be chmod 600, otherwise Traefik refuses to start.
9. Create the dashboard password hash
9.1. sudo apt-get install -y apache2-utils
9.2. For a YAML/TOML config file (single $) -> htpasswd -nbB username 'your-password'
9.3. For a docker compose label (double $$) -> echo $(htpasswd -nbB username 'your-password') | sed -e 's/\$/\$\$/g'
NOTE: this is the #1 reason basic auth "does not work". Compose interpolates $, a mounted config file does not.
Doubling the $ inside a plain .yml/.toml file gives you a broken hash.
================================================================================
1. TRAEFIK v3 (recommended) - image traefik:v3.7
================================================================================
What changed vs v2, in one line: entrypoint level redirect + entrypoint level TLS means you write ONE router per
service instead of the old "app" + "app-secure" pair, and matchers take a single value (use || to combine hosts).
Files in /opt/traefik: docker-compose.yml, traefik.yml (static config), dynamic.yml (middlewares + TLS options), acme.json
--------------------------------------------------------------------------------
[Traefik v3] /opt/traefik/docker-compose.yml
--------------------------------------------------------------------------------
# no "version:" key - it is obsolete in Compose v2+ and prints a warning
services:
traefik:
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC). Remove this line if you disable http3 in traefik.yml
environment:
- TZ=Europe/Istanbul
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./dynamic.yml:/dynamic.yml:ro
- ./acme.json:/acme.json
- ./logs:/var/log/traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.traefik.rule=Host(`traefik.your-domain.com`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth@file"
# TLS comes from the entrypoint default in traefik.yml, so no tls / certresolver label is needed here
networks:
proxy:
external: true
--------------------------------------------------------------------------------
[Traefik v3] /opt/traefik/traefik.yml - static config, httpChallenge
--------------------------------------------------------------------------------
global:
checkNewVersion: true
sendAnonymousUsage: false
api:
dashboard: true
# insecure stays false (default). The dashboard is published through the router label above.
log:
level: ERROR # switch to DEBUG only while troubleshooting, it is very noisy
filePath: /var/log/traefik/traefik.log
format: json
accessLog:
filePath: /var/log/traefik/access.log
bufferingSize: 100
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
http3: {} # HTTP/3 is stable in v3, no experimental flag any more
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false # a container is published only when it carries traefik.enable=true
network: proxy
watch: true
file:
filename: /dynamic.yml
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: your-email@your-domain.com
storage: /acme.json
# Uncomment while testing, then comment out and DELETE acme.json before going live.
# Let's Encrypt production limits you to 5 failed attempts per hostname per hour.
# caServer: https://acme-staging-v02.api.letsencrypt.org/directory
httpChallenge:
entryPoint: web
NOTE: the http -> https redirect does NOT break the ACME http challenge. Traefik answers /.well-known/acme-challenge/
internally before the redirect is applied.
NOTE: prefer tlsChallenge if port 80 is blocked by your provider - swap the httpChallenge block for:
tlsChallenge: {}
--------------------------------------------------------------------------------
[Traefik v3] /opt/traefik/dynamic.yml - middlewares + TLS options
--------------------------------------------------------------------------------
# Single $ here. Do NOT double it, this file is not parsed by docker compose.
http:
middlewares:
traefik-auth:
basicAuth:
users:
- "username:$2y$05$replace-with-your-own-bcrypt-hash"
secure-headers:
headers:
forceSTSHeader: true
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
referrerPolicy: strict-origin-when-cross-origin
customRequestHeaders:
X-Forwarded-Proto: https
# v2 name was ipWhiteList, renamed in v3
office-only:
ipAllowList:
sourceRange:
- "1.2.3.4/32"
tls:
options:
default:
minVersion: VersionTLS12
curvePreferences:
- X25519
- CurveP256
--------------------------------------------------------------------------------
[Traefik v3] start it
--------------------------------------------------------------------------------
1. cd /opt/traefik
2. docker compose up -d
3. docker ps
4. docker compose logs -f traefik # certificate errors show up here
5. Create a CNAME (or A record) for traefik -> visit https://traefik.your-domain.com
6. Log in with the username and the PLAIN password (not the hash)
--------------------------------------------------------------------------------
[Traefik v3] /opt/your-app/docker-compose.yml - publishing your own container
--------------------------------------------------------------------------------
services:
your-app:
image: ghcr.io/github-username/repo-name:latest
container_name: your-app
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
environment:
- TZ=Europe/Istanbul
volumes:
- /etc/localtime:/etc/localtime:ro
- ./data:/data
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.your-app.rule=Host(`your-app.your-domain.com`) || Host(`www.your-app.your-domain.com`)"
- "traefik.http.routers.your-app.entrypoints=websecure"
- "traefik.http.routers.your-app.middlewares=secure-headers@file"
- "traefik.http.services.your-app.loadbalancer.server.port=80" # the port INSIDE the container
networks:
proxy:
external: true
NOTE: v3 matchers take exactly one value. Host(`a.com`, `b.com`) was valid in v2 and is rejected in v3.
Write Host(`a.com`) || Host(`b.com`).
NOTE: your app container does not publish any port to the host. Traefik reaches it over the proxy network.
--------------------------------------------------------------------------------
[Traefik v3] dnsChallenge - wildcard certificate for *.your-domain.com
--------------------------------------------------------------------------------
Use this when you want one certificate covering every subdomain, or when ports 80/443 are not publicly reachable.
traefik.yml:
certificatesResolvers:
letsencrypt-dns:
acme:
email: your-email@your-domain.com
storage: /acme.json
dnsChallenge:
provider: cloudflare # godaddy, digitalocean, route53, ovh... see the provider list in the docs
delayBeforeCheck: 0
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
docker-compose.yml of traefik, environment section:
environment:
- TZ=Europe/Istanbul
- CF_DNS_API_TOKEN=... # Cloudflare
# - GODADDY_API_KEY=... # GoDaddy
# - GODADDY_API_SECRET=...
Labels on the container that should get the wildcard:
- "traefik.http.routers.your-app.tls.certresolver=letsencrypt-dns"
- "traefik.http.routers.your-app.tls.domains[0].main=your-domain.com"
- "traefik.http.routers.your-app.tls.domains[0].sans=*.your-domain.com"
NOTE: GoDaddy restricted its production DNS API to accounts holding 10+ domains, so the v1 recipe further down may
no longer work on a small account. Cloudflare DNS is the easiest free replacement.
NOTE: keep secrets out of the compose file with an .env file next to it and "env_file: .env".
================================================================================
2. TRAEFIK v2 (legacy) - image traefik:v2.11
================================================================================
Only for servers that already run v2. v2 gets security fixes, nothing else. v2.11 is the last minor of the v2 line.
--------------------------------------------------------------------------------
[Traefik v2.11] /opt/traefik/docker-compose.yml with httpChallenge
--------------------------------------------------------------------------------
services:
traefik:
image: traefik:v2.11
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./acme.json:/acme.json
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.entrypoints=http"
- "traefik.http.routers.traefik.rule=Host(`traefik.your-domain.com`)"
- "traefik.http.middlewares.traefik-auth.basicauth.users=username:hashed-password"
- "traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.traefik.middlewares=traefik-https-redirect"
- "traefik.http.routers.traefik-secure.entrypoints=https"
- "traefik.http.routers.traefik-secure.rule=Host(`traefik.your-domain.com`)"
- "traefik.http.routers.traefik-secure.middlewares=traefik-auth"
- "traefik.http.routers.traefik-secure.tls=true"
- "traefik.http.routers.traefik-secure.tls.certresolver=http"
- "traefik.http.routers.traefik-secure.service=api@internal"
- "traefik.http.services.traefik.loadbalancer.server.port=8080"
networks:
proxy:
external: true
NOTE: the hashed-password in a LABEL needs every $ doubled to $$. See step 9.3.
--------------------------------------------------------------------------------
[Traefik v2.11] /opt/traefik/traefik.yml with httpChallenge
--------------------------------------------------------------------------------
api:
dashboard: true
log:
level: ERROR
filePath: "log-file.log"
format: json
accessLog:
filePath: "log-access.log"
bufferingSize: 100
entryPoints:
http:
address: ":80"
https:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
certificatesResolvers:
http:
acme:
email: your-email@your-domain.com
storage: acme.json
httpChallenge:
entryPoint: http
--------------------------------------------------------------------------------
[Traefik v2.11] your-container docker-compose.yml
--------------------------------------------------------------------------------
services:
your-container-name:
image: ghcr.io/github-username/repo-name:latest
container_name: your-container-name
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
volumes:
- /etc/localtime:/etc/localtime:ro
- ./data:/data
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.your-container-name.entrypoints=http"
- "traefik.http.routers.your-container-name.rule=Host(`your-container-name.your-domain.com`, `www.your-container-name.your-domain.com`)"
- "traefik.http.middlewares.your-container-name-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.your-container-name.middlewares=your-container-name-https-redirect"
- "traefik.http.routers.your-container-name-secure.entrypoints=https"
- "traefik.http.routers.your-container-name-secure.rule=Host(`your-container-name.your-domain.com`, `www.your-container-name.your-domain.com`)"
- "traefik.http.routers.your-container-name-secure.tls=true"
- "traefik.http.routers.your-container-name-secure.tls.certresolver=http"
- "traefik.http.routers.your-container-name-secure.service=your-container-name"
- "traefik.http.services.your-container-name.loadbalancer.server.port=80"
networks:
proxy:
external: true
NOTE: mounting /var/run/docker.sock into an application container (the old version of this gist did) gives that app
root on the host. Only Traefik needs the socket.
================================================================================
3. TRAEFIK v1 (EOL) - image traefik:v1.7
================================================================================
v1.7 reached end of life in 2019 and receives no fixes. Kept for reference on old servers only.
Config format is TOML, labels use the old traefik.frontend.* / traefik.backend.* scheme.
--------------------------------------------------------------------------------
[Traefik v1.7] docker-compose.yml with dnsChallenge
--------------------------------------------------------------------------------
version: '3.7'
services:
reverse-proxy:
image: traefik:v1.7
restart: always
container_name: traefik
ports:
- 80:80
- 443:443
expose:
- 8080
networks:
- external
- internal
environment:
- GODADDY_API_KEY=...
- GODADDY_API_SECRET=...
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /opt/traefik/traefik.toml:/traefik.toml
- /opt/traefik/acme.json:/acme.json
labels:
- "traefik.backend=traefik"
- "traefik.docker.network=external"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:traefik.yourdomain.com"
- "traefik.port=8080"
- "traefik.frontend.headers.forceSTSHeader=true"
- "traefik.frontend.headers.STSSeconds=315360000"
- "traefik.frontend.headers.STSIncludeSubdomains=true"
- "traefik.frontend.headers.STSPreload=true"
networks:
external:
external: true
internal:
--------------------------------------------------------------------------------
[Traefik v1.7] traefik.toml with dnsChallenge
--------------------------------------------------------------------------------
Update users on entryPoints.traefik with -> echo $(htpasswd -nb username password) | sed -e s/\\$/\\$\\$/g
Update your-domain and your-email fields, and the provider on acme.dnsChallenge.
debug = false
loglevel = "ERROR"
defaultEntryPoints = ["https", "http"]
[entryPoints]
[entryPoints.http]
address = ":80"
[entryPoints.http.redirect]
entryPoint = "https"
[entryPoints.https]
address = ":443"
[entryPoints.https.tls]
[entryPoints.traefik]
address = ":8080"
[entryPoints.traefik.auth.basic]
users = ["username:hashed-password"]
[api]
entryPoint = "traefik"
[retry]
[docker]
endpoint = "unix:///var/run/docker.sock"
domain = "your-domain.com"
watch = true
exposedByDefault = false
[acme]
email = "your-email@your-domain.com"
storage = "acme.json"
onHostRule = true
entryPoint = "https"
[acme.dnsChallenge]
provider = "godaddy"
delayBeforeCheck = 0
[[acme.domains]]
main = "*.your-domain.com"
sans = ["your-domain.com"]
--------------------------------------------------------------------------------
[Traefik v1.7] your-container docker-compose.yml
--------------------------------------------------------------------------------
version: "3.9"
services:
your-container-name:
image: ghcr.io/github-username/repo-name:latest
restart: always
container_name: your-container-name
networks:
- web
labels:
- traefik.docker.network=external
- traefik.backend=your-container-name
- traefik.frontend.rule=Host:your-container-name.your-domain.com
- traefik.enable=true
- traefik.port=80
networks:
web:
name: external
================================================================================
4. GHCR.IO LOGIN, FIRST CONTAINER, TROUBLESHOOTING
================================================================================
1. Log in to GitHub Container Registry
1.1. Create a GitHub Personal Access Token (classic) with read:packages (and write:packages if you push from CI)
1.2. export CR_PAT=ghp_yourtoken
1.3. echo $CR_PAT | docker login ghcr.io -u github-username --password-stdin
1.4. The token is stored in plain text in ~/.docker/config.json. On a shared server use a credential helper.
NOTE: docker.pkg.github.com (GitHub Packages Docker registry) is retired. Image names are now
ghcr.io/OWNER/IMAGE:TAG - all lowercase.
NOTE: a public package needs no login at all on the server, only for pushing.
2. Run your container
2.1. sudo mkdir -p /opt/your-app && cd /opt/your-app
2.2. nano docker-compose.yml (paste the app compose from the section of your Traefik version)
2.3. docker compose up -d
2.4. docker ps
2.5. Create a DNS record for your-app -> visit https://your-app.your-domain.com
3. Update a container to the latest image
3.1. docker compose pull
3.2. docker compose up -d
3.3. docker image prune -f
4. Troubleshooting
- 404 page not found -> the router did not match. Check the Host rule, and that the container really is
on the proxy network: docker inspect -f '{{json .NetworkSettings.Networks}}' your-app
- Bad Gateway -> Traefik found the router but not the app. loadbalancer.server.port must be the port
INSIDE the container, not a published host port.
- TRAEFIK DEFAULT CERT -> no certificate was issued. docker compose logs traefik | grep -i acme
- unable to generate a certificate -> DNS not pointing at this server yet, or port 80 is firewalled, or you hit the
Let's Encrypt rate limit (5 failures per hostname per hour). Use the staging
caServer while you debug.
- basic auth loop / 401 -> the $ escaping. Single $ in a mounted yml file, double $$ in a compose label.
- acme.json permissions -> must be chmod 600, owned by root.
- port 80 already in use -> another nginx/apache is running: sudo systemctl disable --now nginx apache2
- firewall -> sudo ufw allow 80,443/tcp && sudo ufw allow 443/udp (443/udp only for HTTP/3)
- validate the static config -> docker run --rm -v /opt/traefik/traefik.yml:/traefik.yml traefik:v3.7 version
================================================================================
5. v2 -> v3 MIGRATION CHECKLIST
================================================================================
1. Router rules: a matcher now takes a single value.
v2: Host(`a.com`, `b.com`) v3: Host(`a.com`) || Host(`b.com`)
2. Renamed matchers: Headers -> Header, HeadersRegexp -> HeaderRegexp, HostHeader removed (use Host).
3. PathPrefix no longer accepts a regular expression, and {name} placeholders are gone. Use PathRegexp.
4. Middleware renamed: ipWhiteList -> ipAllowList (label: traefik.http.middlewares.x.ipallowlist.sourcerange).
5. Removed Headers middleware options: sslRedirect, sslTemporaryRedirect, sslHost, sslForceHost, featurePolicy.
Use a redirectScheme middleware or the entrypoint redirection instead of sslRedirect.
6. StripPrefix lost forceSlash.
7. Docker provider was split: swarmMode is gone, Docker Swarm now uses the separate "swarm" provider.
8. experimental.http3 is gone - HTTP/3 is configured per entrypoint with "http3: {}" and needs 443/udp published.
9. Removed providers: Marathon, Rancher v1, and the Pilot service. tracing.datadog.globaltag removed.
10. Simplify while you are there: move the http->https redirect and the certResolver onto the entrypoints, then delete
every "-secure" duplicate router and its redirect middleware.
11. Escape hatch if you cannot rewrite all the rules at once - keep the v2 rule syntax in the static config:
core:
defaultRuleSyntax: v2
Then move to v3 syntax router by router with the label
traefik.http.routers.your-app.rulesyntax=v3
12. Test with the Let's Encrypt staging caServer first so a broken migration does not burn your rate limit.
References:
- Traefik v3 docs -> https://doc.traefik.io/traefik/
- v2 to v3 migration -> https://doc.traefik.io/traefik/migrate/v2-to-v3/
- Let's Encrypt limits -> https://letsencrypt.org/docs/rate-limits/
- Docker install docs -> https://docs.docker.com/engine/install/ubuntu/
@fatihyildizhan

Copy link
Copy Markdown
Author

I've updated the Gist for Docker 23 + Traefik v2.9.9 and v1.7 + Let's Encrypt + Github Registry V2 ghcr.io on 28 March 2023.

@revskill10

Copy link
Copy Markdown

Can't proxy to container though. I already setup subdomain mycontainer.mydomain.com point to IP of server.

@fatihyildizhan

Copy link
Copy Markdown
Author

I've updated the Gist for Docker 29 + Traefik v3.7, v2.11 and v1.7 + Let's Encrypt + Github Registry V2 ghcr.io on 4 September 2026.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment