to build a project (code + config) – production ready

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.web and entrypoints.websecure define listeners on ports 80 and 443. (Traefik Docs)
  • The web entrypoint redirects all traffic to websecure, which is the standard Traefik redirect pattern. (Traefik Docs)
  • tls.certresolver=le tells 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@internal and protected with BasicAuth instead of insecure mode. (Traefik Docs)

7) DNS records

Create DNS records like:

  • A app.yourdomain.com -> your_server_ip
  • A 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.com
  • https://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 image
on:
push:
branches: ["main"]
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/devops-starter
jobs:
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_AUTH hash formatting
  • acme.json missing 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.

Leave a comment