here’s a copyable starter project you can build end to end.
It gives you:
- a tiny Node app in Docker
- Traefik in front of it
- hostname-based routing on localhost
- a GitHub Actions workflow that builds the image
- a path to deploy the same stack to a server later
This matches Traefik’s current Docker provider pattern, where Traefik watches Docker and picks up routing config from container labels. (Traefik Labs Documentation)
Project structure
devops-starter/├── app/│ ├── package.json│ └── server.js├── .github/│ └── workflows/│ └── docker.yml├── .dockerignore├── Dockerfile└── compose.yml
1) app/package.json
{ "name": "devops-starter", "version": "1.0.0", "description": "Simple Node app behind Traefik", "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) => { const body = { ok: true, message: "Hello from the app behind Traefik", 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
This uses an official Node image and a fixed major version tag, which is in line with GitHub’s Dockerfile guidance. (GitHub Docs)
FROM node:20-alpineWORKDIR /appCOPY app/package.json ./RUN npm install --omit=devCOPY app/server.js ./ENV PORT=3000EXPOSE 3000CMD ["npm", "start"]
4) .dockerignore
node_modulesnpm-debug.log.git.githubDockerfilecompose.yml
5) compose.yml
This follows the same core idea as Traefik’s Docker Compose examples: enable the Docker provider, disable exposing containers by default, define an HTTP entrypoint, and add labels to the app container so Traefik creates the router automatically. (Traefik Labs Documentation)
services: traefik: image: traefik:v3.0 command: - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entryPoints.web.address=:80" ports: - "80:80" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" app: build: context: . dockerfile: Dockerfile labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`app.localhost`)" - "traefik.http.routers.app.entrypoints=web"
A couple of notes:
api.insecure=trueis fine for learning locally, but not for a public server. Traefik’s dashboard docs treat this as something to secure for real deployments. (Traefik Labs Documentation)- Because both services are in the same Compose stack, Docker networking handles connectivity between Traefik and the app. That is the same pattern used in Docker and Traefik quick-start examples. (Traefik Labs Documentation)
6) .github/workflows/docker.yml
GitHub’s docs show Docker builds in Actions using actions/checkout and docker/build-push-action. This workflow keeps it simple: it builds on every push to main, and you can later extend it to push to Docker Hub or GHCR. (GitHub Docs)
name: Build Docker imageon: push: branches: ["main"] pull_request:jobs: build: runs-on: ubuntu-latest steps: - name: Check out repo uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: false tags: devops-starter:latest
7) Run it locally
From the project root:
docker compose up -d --build
Then open:
http://app.localhosthttp://localhost:8080for the Traefik dashboard
Traefik’s Docker quick-start uses the same localhost-style host rule pattern, and the dashboard is commonly exposed on port 8080 in the getting-started setup. (Traefik Labs Documentation)
To stop:
docker compose down
To view logs:
docker compose logs -f
8) What’s happening
When you visit http://app.localhost:
- your browser sends a request to port 80
- Traefik receives it
- Traefik checks Docker-discovered labels
- the router rule
Host(\app.localhost`)` matches - Traefik forwards the request to the
appcontainer
That “dynamic config from Docker labels” model is a central part of Traefik’s configuration overview and Docker provider docs. (Traefik Labs Documentation)
9) Make it feel more real
Add a second app to prove routing works.
Update compose.yml like this:
services: traefik: image: traefik:v3.0 command: - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entryPoints.web.address=:80" ports: - "80:80" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" app: build: context: . dockerfile: Dockerfile labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`app.localhost`)" - "traefik.http.routers.app.entrypoints=web" whoami: image: traefik/whoami labels: - "traefik.enable=true" - "traefik.http.routers.whoami.rule=Host(`whoami.localhost`)" - "traefik.http.routers.whoami.entrypoints=web"
Then:
http://app.localhost→ your apphttp://whoami.localhost→ sample Traefik test service
That mirrors Traefik’s own examples for exposing services with Docker labels. (Traefik Labs Documentation)
10) How to deploy this later
For a simple first deployment:
- get a Linux VM
- install Docker and Docker Compose
- copy this project to the server
- point a domain at the server IP
- swap localhost routing for your real domain
- add HTTPS with Traefik + Let’s Encrypt
Traefik documents Docker standalone setup, HTTPS entrypoints, and ACME/Let’s Encrypt support as part of its normal production path. (Traefik Labs Documentation)
Your production router label would look more like:
- "traefik.http.routers.app.rule=Host(`app.yourdomain.com`)"
11) Resume-worthy version of this project
Once this is live, you can honestly describe it like this:
Built and deployed a containerized Node.js service using Docker and Traefik with hostname-based routing and automated image builds via GitHub Actions.
That is a real DevOps project, not tutorial-only practice.
12) Best next upgrades
After this works, do these in order:
- add
/healthzendpoint - add a test job to GitHub Actions
- push built images to GHCR or Docker Hub
- deploy on a small cloud VM
- add HTTPS with Let’s Encrypt
- add Prometheus/Grafana later
GitHub’s Actions docs already provide the build-and-publish direction if you want to turn your build-only workflow into a registry-pushing workflow. (GitHub Docs)
13) The shortest possible checklist
Create files → run:
docker compose up -d --build
Visit:
http://app.localhosthttp://localhost:8080
Push to GitHub → Actions builds the image definition automatically.