Self-Hosted Deployments with GitHub Runners and Traefik
Running multiple side projects on a single VPS requires a bit of orchestration. Here’s how I set up automated deployments using self-hosted GitHub Actions runners with Traefik handling routing and SSL.
The Architecture
flowchart LR
subgraph "Developer 🧑🏿💻"
A[git push]
end
subgraph GitHub
B[Repository]
C[Actions Workflow]
end
subgraph VPS
D[Self-hosted Runner]
E[Docker Build]
F[Traefik :80/:443]
G[portfolio container]
H[bookoko container]
I[other-service container]
end
subgraph Internet
J[Users]
end
A --> B
B --> C
C -->|triggers| D
D --> E
E -->|deploys| G
E -->|deploys| H
E -->|deploys| I
F --> G
F --> H
F --> I
J -->|jeyfel.fr| F
J -->|bookoko.com| F
J -->|other-service.com| F
Each service runs in its own Docker container. Traefik sits in front, routing requests based on hostname and handling SSL certificates via Let’s Encrypt.
Why Self-Hosted Runners?
GitHub-hosted runners are free for public repos and offer 2,000 free minutes/month for private repos. But they run on GitHub’s infrastructure, so deploying to your VPS requires either SSH access or pushing to a container registry.
The registry option gets complicated for private repos: free registries like GitHub Container Registry and Docker Hub typically require your repo to be public. One workaround is mirroring your private GitHub repo to a private GitLab repo, using GitLab’s free container registry, then pulling that image from your GitHub workflow. It works, but adds complexity.
With a self-hosted runner on your VPS:
- No SSH keys to manage : the runner is already on the VPS
- No external registry : build images locally
- Faster deployments : no image upload/download
- Full access : run any command, access local resources
The tradeoff is you’re responsible for maintaining the runner.
Setting Up the GitHub Runner
On your VPS:
# Create a directory for the runner
mkdir ~/actions-runner && cd ~/actions-runner
# Download the latest runner
curl -o actions-runner-linux-x64-2.321.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64-2.321.0.tar.gz
# Configure (get token from repo Settings → Actions → Runners)
./config.sh --url https://github.com/USERNAME/REPO --token YOUR_TOKEN --labels self-hosted,production
# Install and start as a service
sudo ./svc.sh install
sudo ./svc.sh start
The --labels flag is important. Your workflow uses these to target the right runner.
One Runner Per Repository
A single runner instance can only be registered to one entity (repo or org). If you have multiple repos, you have two options:
Option A: Multiple runner instances
Create a separate directory for each repo:
mkdir ~/actions-runner-portfolio
mkdir ~/actions-runner-bookoko
# Configure each with its own repo token
Both can run on the same machine simultaneously.
Option B: Organization-level runner
Move your repos to a GitHub organization and register the runner at the org level. All repos in the org share it.
I went with Option A for simplicity.
Traefik Configuration
Traefik runs as a Docker container and automatically discovers other containers via Docker labels. Here’s the core setup:
# docker-compose.traefik.yml
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.http.address=:80"
- "--entrypoints.https.address=:443"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=http"
- "--certificatesresolvers.le.acme.email=your@email.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
networks:
- traefik-public
networks:
traefik-public:
external: true
Create the network first:
docker network create traefik-public
Service Configuration
Each service connects to Traefik via Docker labels. Here’s my portfolio’s docker-compose.yml:
services:
portfolio:
build:
context: .
dockerfile: Dockerfile
container_name: portfolio
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# HTTP → HTTPS redirect
- "traefik.http.routers.portfolio-http.entrypoints=http"
- "traefik.http.routers.portfolio-http.rule=Host(`jeyfel.fr`)"
- "traefik.http.routers.portfolio-http.middlewares=https-redirect"
# HTTPS router
- "traefik.http.routers.portfolio-https.entrypoints=https"
- "traefik.http.routers.portfolio-https.rule=Host(`jeyfel.fr`)"
- "traefik.http.routers.portfolio-https.tls=true"
- "traefik.http.routers.portfolio-https.tls.certresolver=le"
# Service port
- "traefik.http.services.portfolio.loadbalancer.server.port=80"
networks:
- traefik-public
networks:
traefik-public:
external: true
Key points:
traefik.enable=true: opt-in to Traefik routingHost()rule : route based on domaincertresolver=le: automatic Let’s Encrypt certificates- All services share the
traefik-publicnetwork
The GitHub Actions Workflow
name: Build and Deploy
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: [self-hosted, production]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty .env file
run: echo "" > .env
- name: Build
run: docker compose build
- name: Deploy
run: docker compose up -d
- name: Cleanup
run: docker system prune -f
The runs-on: [self-hosted, production] targets runners with both labels. The actions/checkout@v4 step checks out the code to the runner’s workspace. No need to manually clone or maintain a directory on the VPS.
Multi-Stage Docker Build
For static sites, I use a multi-stage build to keep the final image small:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The final image is just nginx serving static files. Which is around 40MB.
Deployment Flow
- Push to
mainbranch - GitHub triggers the workflow
- Self-hosted runner picks up the job
- Runner checks out the code
- Docker builds the image locally
- Docker Compose restarts the container
- Traefik automatically detects the new container
- Site is live with SSL
The whole process takes under a minute.
Troubleshooting
Runner not picking up jobs?
Check runner status: ./svc.sh status
Verify labels match between workflow and runner configuration.
Container not accessible via Traefik?
Ensure the container is on the traefik-public network:
docker network inspect traefik-public
SSL certificate issues?
Check Traefik logs: docker logs traefik
Make sure port 80 is accessible for the HTTP challenge.
Conclusion
This setup serves me well for running multiple projects on a single VPS. The self-hosted runner eliminates the need for SSH key management or external registries, and Traefik makes adding new services trivial. Just add the right labels and it handles routing and SSL automatically.
The initial setup takes an hour or two, but subsequent projects are just a matter of copying the docker-compose pattern and pushing to GitHub.