Three deployment approaches I've tried
The classic journey: from "scp and pray" to "push to main and forget". Here's what hurts in each.
Option 1. Manual: copying code to the host
That's how I started, and it's fine for the very first launch.
# on your machine
rsync -avz --exclude node_modules ./dist/ deploy@server:/var/www/site/
ssh deploy@server
cd /var/www/site && docker compose up -d
Pros: nothing to set up. Cons, which showed up fast: copied the wrong thing / forgot an exclude / old files linger on the host, and no rollback at all. OK for static pages, no-go for anything alive.
Option 2. Manual with image pulls
Next stage: code is built into an image (locally or in CI), I only pull on the host.
docker login registry.example.com
docker compose pull
docker compose up -d
docker image prune -f
Pros: the host always gets a version built and checked by CI,
configs and secrets stay next to it. Cons: manual work remains,
which means a forgotten docker compose pull and "why is the site still the old version?".
By the way, I still use this one for manual tweaks between releases.
Option 3. Automatic via GitLab CI
Now all my projects live like this: push to main → lint, tests, build, deploy.
The general pattern I apply:
stages: [QualityOfCode, Test, Build, Deploy]
Build:build:
script:
- pnpm run build # or docker build + push to registry
Deploy:site:
stage: Deploy
script:
# release dir + current symlink — rollback in seconds
- rsync -a dist/ "$DEPLOY_DIR/releases/$CI_COMMIT_SHORT_SHA/"
- ln -sfn "$DEPLOY_DIR/releases/$CI_COMMIT_SHORT_SHA" "$DEPLOY_DIR/current"
- docker compose up -d
- curl -fsS https://example.com | grep -q "ok" # smoke test is mandatory
Two delivery approaches, I use both:
- SSH delivery — the job connects to the host with a key (
before_scriptwith ssh-agent). Simple, but you have to manage keys. - DooD (Docker outside of Docker) — the runner runs on the same host as production and drives the host's docker via a mounted socket. No SSH at all, this is how kovss.com works today.
Main lessons from the automatic path:
- A smoke test after deploy is not decoration. Twice it returned me to a working version instead of a broken one.
- Releases are directories + a
currentsymlink. Rollback = flip the link. - Secrets live on the host (
shared/.env), not in GitLab variables, if runners are shared.
The manual options never went away: manual image pulls are my fallback when CI is down but a fix is needed right now.