Deploy from scratch — GitLab, a VPS, and Traefik
A simple path from empty repo to HTTPS on a VPS — GitLab CI builds the image, the server pulls it, Traefik handles routing and certificates.
- devops
- gitlab
- traefik
- vps
You do not need Kubernetes to ship a side project. A single VPS, GitLab CI, Docker, and Traefik will take you from git push to a real hostname with TLS — without paying a platform tax for every deploy.
This is the shape of the workflow I reach for when something needs to live on the internet and stay boring to operate.
Local app
Dockerfile, one process, env-based config
GitLab
Push main — source of truth
CI build
Test, build image, push registry
VPS
Pull tag, restart Compose stack
Traefik
Host routing + Let’s Encrypt TLS
Why this stack
GitLab already has CI and a container registry. You are not wiring three vendors together on day one.
A VPS is cheap, predictable, and yours. You learn real ops without a control-plane dissertation.
Traefik sits in front as the reverse proxy. You point DNS at the box once; apps announce their hostnames with Docker labels. Certificates stop being a ceremony.
From empty disk to first HTTPS
- Provision the VPS — Ubuntu LTS is fine. Open 80 and 443. Lock SSH to keys. Create a non-root deploy user.
- Install Docker — Engine + Compose. Traefik and your apps will share a Docker network.
- Run Traefik first — Mount the Docker socket (read-only), enable the Let’s Encrypt resolver, expose 80/443. Until Traefik is healthy, nothing else matters.
- Point DNS —
Arecord forapp.example.com(and ideally a wildcard later) to the VPS IP. Wait for propagation before you expect certificates to succeed. - Add the app service — Compose file with your image, env file, and Traefik labels for the router rule and TLS. Join the same network Traefik uses.
- Wire GitLab CI — Job to build/push on
main. Deploy job that pulls on the VPS and runsdocker compose up -d. Store registry credentials and SSH key as protected CI variables.
That is the whole loop. After it works once, every push is the same motion. Below is the minimal glue that makes the checklist real.
Shared network
Create one external network once. Traefik and every app join it. Apps never publish ports to the host.
docker network create web
Traefik on the VPS
Keep Traefik in its own directory on the server, e.g. /opt/traefik/. It is infrastructure, not an app you rebuild on every push.
# /opt/traefik/docker-compose.yml
services:
traefik:
image: traefik:v3.3
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=web
- --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.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- [email protected]
- --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:
- web
networks:
web:
external: true
mkdir -p /opt/traefik/letsencrypt
touch /opt/traefik/letsencrypt/acme.json
chmod 600 /opt/traefik/letsencrypt/acme.json
cd /opt/traefik && docker compose up -d
Only Traefik binds 80/443. Everything else stays on web.
App: Dockerfile + Compose labels
A boring Dockerfile is enough — one process, listen on an internal port, config from env.
# Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
On the VPS, the app Compose file is what Traefik watches. Labels are the contract: hostname, entrypoint, certificate resolver.
# /opt/apps/myapp/docker-compose.yml
services:
app:
image: registry.gitlab.com/you/myapp:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: .env
networks:
- web
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 5s
retries: 3
labels:
- traefik.enable=true
- traefik.docker.network=web
- traefik.http.routers.myapp.rule=Host(`app.example.com`)
- traefik.http.routers.myapp.entrypoints=websecure
- traefik.http.routers.myapp.tls.certresolver=le
- traefik.http.services.myapp.loadbalancer.server.port=3000
networks:
web:
external: true
No ports: on the app. Traefik finds the container on web and forwards to port 3000 inside it. Put secrets in /opt/apps/myapp/.env on the server — not in git.
GitLab CI: build, push, pull
Protected CI variables you will need:
SSH_PRIVATE_KEY— deploy user key for the VPSDEPLOY_HOST— VPS hostname or IPDEPLOY_USER— non-root user that can run DockerCI_REGISTRY_*— usually provided by GitLab automatically
# .gitlab-ci.yml
stages:
- build
- deploy
variables:
IMAGE: $CI_REGISTRY_IMAGE
# Pin every deploy to this pipeline's SHA — not :latest
TAG: $CI_COMMIT_SHORT_SHA
build:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- docker build -t "$IMAGE:$TAG" -t "$IMAGE:latest" .
- docker push "$IMAGE:$TAG"
- docker push "$IMAGE:latest"
deploy:
stage: deploy
image: alpine:3.21
needs: [build]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apk add --no-cache openssh-client
- eval "$(ssh-agent -s)"
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- |
ssh "$DEPLOY_USER@$DEPLOY_HOST" "\
docker login -u '$CI_REGISTRY_USER' -p '$CI_REGISTRY_PASSWORD' '$CI_REGISTRY' && \
cd /opt/apps/myapp && \
IMAGE_TAG=$TAG docker compose pull && \
IMAGE_TAG=$TAG docker compose up -d"
Push to main. CI builds and tags the image with the commit SHA, SSHes to the VPS, pulls that tag, and restarts Compose. Traefik picks up the new container via Docker labels — no Traefik restart, no cert ceremony.
Traefik’s job (and only its job)
Traefik should not know your business logic. It should:
- terminate TLS
- route
Host()rules to the right container - renew certificates
- optionally redirect HTTP → HTTPS
Keep app ports off the public interface. Only Traefik publishes 80/443. Everything else listens on the internal Docker network.
Keep the first version boring
- One VPS, one Compose stack, one Traefik instance.
- Pin image tags (or digests) in production; avoid
:latestas your release contract. - Put secrets in CI variables / an env file on the server — not in the repo.
- Add a healthcheck so Compose does not flip traffic to a dead container.
- Backups and monitoring can wait until the site actually has users. Shipping first is the point.
When to outgrow it
Move on when you need multi-region, hard isolation between tenants, or a team that cannot safely SSH to a box. Until then, this path is enough: push → build → pull → Traefik.
Small surface area. Real HTTPS. No diagramming SaaS required.