Here’s the production version of the starter project: real domain, automatic HTTPS, HTTP→HTTPS redirect, and a secured Traefik dashboard.
This uses Traefik’s Docker provider with labels for routing, a Let’s Encrypt certificate resolver for TLS, and the dashboard in secure mode rather than api.insecure=true. Traefik’s docs recommend securing the dashboard and show Docker Compose setups for HTTPS with ACME. (Traefik Docs)
Before you start
You need:
- a Linux server with Docker and Docker Compose
- a domain or subdomain pointing to that server
- ports 80 and 443 open to the internet
For the HTTP-01 challenge, Traefik’s ACME guide requires the app to be reachable publicly and the domain to point to the Traefik instance. (Traefik Docs)
Recommended structure
devops-starter/├── app/│ ├── package.json│ └── server.js├── letsencrypt/│ └── acme.json├── .github/│ └── workflows/│ └── publish.yml├── .env├── Dockerfile└── compose.yml
1) app/package.json
{
"name": "devops-starter",
"version": "1.0.0",
"description": "Node app behind Traefik with HTTPS",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"license": "MIT"
}
2) app/server.js
const http = require("http");const PORT = process.env.PORT || 3000;const server = http.createServer((req, res) => { if (req.url === "/healthz") { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: true })); } const body = { ok: true, message: "Hello from production", method: req.method, url: req.url, hostname: req.headers.host, time: new Date().toISOString() }; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(body, null, 2));});server.listen(PORT, () => { console.log(`Server running on port ${PORT}`);});
3) Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY app/package.json ./
RUN npm install --omit=dev
COPY app/server.js ./
ENV PORT=3000
EXPOSE 3000
CMD ["npm", "start"]
4) .env
Replace these with your real values:
DOMAIN=app.yourdomain.com
TRAEFIK_DASHBOARD_HOST=traefik.yourdomain.com
LETSENCRYPT_EMAIL=you@example.com
# Generate this with: htpasswd -nb admin 'your-strong-password'
# Then double the $ signs when putting it here for docker labels
TRAEFIK_BASIC_AUTH=admin:$$apr1$$replace$$with-real-hash
Traefik’s BasicAuth middleware supports htpasswd-style hashes, and its docs note that when using Docker labels, dollar signs need escaping. (Traefik Docs)
5) Create the certificate storage file
Run this once on the server:
mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
Traefik’s ACME examples store certificates in acme.json, and the file should be writable by Traefik while remaining protected. (Traefik Docs)
6) compose.yml
services:
traefik:
image: traefik:v3.4
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--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=${LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge=true"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
- "--accesslog=true"
- "--log.level=INFO"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
labels:
- "traefik.enable=true"
# Secure dashboard
- "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_DASHBOARD_HOST}`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls=true"
- "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=${TRAEFIK_BASIC_AUTH}"
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls=true"
- "traefik.http.routers.app.tls.certresolver=le"
# Tell Traefik which internal port the app listens on
- "traefik.http.services.app.loadbalancer.server.port=3000"
Why these labels and flags matter:
- Traefik uses Docker labels as dynamic config when Docker is the provider. (Traefik Docs)
entrypoints.webandentrypoints.websecuredefine listeners on ports 80 and 443. (Traefik Docs)- The
webentrypoint redirects all traffic towebsecure, which is the standard Traefik redirect pattern. (Traefik Docs) tls.certresolver=letells the router to request and renew certificates through the Let’s Encrypt resolver you defined. (Traefik Docs)- The dashboard can be exposed securely through
api@internaland protected with BasicAuth instead of insecure mode. (Traefik Docs)
7) DNS records
Create DNS records like:
A app.yourdomain.com -> your_server_ipA traefik.yourdomain.com -> your_server_ip
If you use IPv6, add AAAA records too. The names used in your router Host(...) rules must resolve to the server running Traefik for ACME issuance to work. (Traefik Docs)
8) First deploy
From the project folder on your server:
docker compose up -d --build
Then open:
https://app.yourdomain.comhttps://traefik.yourdomain.com
On first startup, Traefik should obtain certificates automatically via Let’s Encrypt as requests arrive for matching routers using the resolver. (Traefik Docs)
Useful commands:
docker compose logs -f traefik
docker compose logs -f app
docker compose ps
9) Generate the dashboard password hash
If htpasswd is installed:
htpasswd -nb admin 'your-strong-password'
Put the result in .env as TRAEFIK_BASIC_AUTH=..., but replace every $ with $$ so Docker Compose does not treat them as variable substitutions. Traefik’s BasicAuth docs explicitly mention escaping dollar signs in Docker label contexts. (Traefik Docs)
10) Publish the image from GitHub Actions
If you want Actions to build and push your app image to GHCR, use this workflow.
.github/workflows/publish.yml
name: Build and publish imageon: push: branches: ["main"]env: IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/devops-starterjobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Check out repository uses: actions/checkout@v4 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_NAME }} tags: | type=raw,value=latest type=sha - name: Build and push uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }}
GitHub’s docs recommend docker/build-push-action for building and publishing images, and GHCR uses the Container registry at ghcr.io. Workflows can authenticate with GITHUB_TOKEN when package permissions are configured appropriately. (GitHub Docs)
If you switch to pulling the published image on the server, replace the app service in Compose with:
app:
image: ghcr.io/YOUR_GITHUB_USERNAME/devops-starter:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls=true"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.services.app.loadbalancer.server.port=3000"
11) What makes this “production enough” for a first real project
This version is much closer to a real deployment because it has:
- automatic HTTPS
- secure dashboard access
- HTTP→HTTPS redirect
- restart policy
- access logs
- a health endpoint
- optional CI image publishing
Those pieces line up with Traefik’s Docker standalone guidance and dashboard/ACME docs. (Traefik Docs)
12) Common failure points
If it does not work, the usual causes are:
- DNS not pointing at the server
- ports 80/443 blocked by firewall or cloud security group
- invalid
TRAEFIK_BASIC_AUTHhash formatting acme.jsonmissing or wrong permissions- router hostnames not matching the browser request
Those are the most common things that prevent Traefik from issuing certs or matching routers in Docker setups. (Traefik Docs)
13) Resume line for this project
Deployed a containerized Node.js service behind Traefik with automatic Let’s Encrypt TLS, secure reverse-proxy routing, and GitHub Actions image publishing to GHCR.
That is solid, real DevOps experience.