Blog
#03 Latest

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.

End-to-end deploy path

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

  1. Provision the VPS — Ubuntu LTS is fine. Open 80 and 443. Lock SSH to keys. Create a non-root deploy user.
  2. Install Docker — Engine + Compose. Traefik and your apps will share a Docker network.
  3. 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.
  4. Point DNSA record for app.example.com (and ideally a wildcard later) to the VPS IP. Wait for propagation before you expect certificates to succeed.
  5. 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.
  6. Wire GitLab CI — Job to build/push on main. Deploy job that pulls on the VPS and runs docker 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.

Bash
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.

YAML
# /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
Bash
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
# 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.

YAML
# /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 VPS
  • DEPLOY_HOST — VPS hostname or IP
  • DEPLOY_USER — non-root user that can run Docker
  • CI_REGISTRY_* — usually provided by GitLab automatically
YAML
# .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 :latest as 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.