[ OK ]Initializing kernel...
~/im/blog
Hire Me

Let's Talk

Got an infrastructure problem or need an extra hand? I'm open to discussing new projects.

Get in touch

Connect

Find me on social media and professional networks.

© 2026 Irfan Miral. All rights reserved.Developed byIrfan Miral
Privacy PolicyTerms & Conditions
HomeServicesAbout/ResumeBlogContactTools
2026-01-22• 5 min read

A GitLab CI/CD Pipeline That Covers Most Small Projects

DevOps GitLab CI/CD Automation

Advertisement

When I take over a small project with zero CI/CD, deployment usually means someone SSHing into the server, pulling the latest commit, and restarting a service by hand.

It works until it doesn't. A forgotten npm install, a deploy from the wrong branch, or a missed environment variable is all it takes to break production. The fix is not a complicated pipeline with a dozen custom stages. The fix is a short .gitlab-ci.yml that reliably does the exact same three things every single time: build, test, and deploy.

The shape of it

stages:
  - build
  - test
  - deploy

variables:
  GIT_DEPTH: 1

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/

build:
  stage: build
  image: node:20
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

test:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm run test -- --ci

GIT_DEPTH: 1 keeps git checkouts incredibly fast on repositories with long histories. Caching node_modules by branch ensures that most pipeline runs skip a full NPM reinstall. The build stage produces a compiled artifact that the deploy stage reuses. This guarantees the code only gets built once per pipeline run, not once per stage.

Deploying over SSH

For small projects, setting up a Kubernetes manifest or a container registry is often more infrastructure overhead than the project actually needs. Plain rsync over SSH, triggered from CI, securely covers a surprising number of real deployments:

deploy_staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$STAGING_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan -H "$STAGING_HOST" >> ~/.ssh/known_hosts
  script:
    - rsync -az --delete dist/ deploy@$STAGING_HOST:/var/www/staging/
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

The SSH key lives strictly in a masked, protected CI/CD variable, never in the repository. The ssh-keyscan command populates known_hosts so the pipeline job doesn't hang indefinitely on a host-key verification prompt. Finally, rsync --delete keeps the remote directory in exact sync with the build output rather than slowly accumulating stale files over time.

Production gets a manual gate

Staging deploys automatically on every push to the develop branch. Production deploys from main, but only when someone clicks the button:

deploy_production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$PRODUCTION_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan -H "$PRODUCTION_HOST" >> ~/.ssh/known_hosts
  script:
    - rsync -az --delete dist/ deploy@$PRODUCTION_HOST:/var/www/production/
  environment:
    name: production
    url: https://example.com
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  when: manual

when: manual turns the job into a literal button in GitLab's pipeline view rather than a script that fires automatically. Combined with GitLab's environment tracking, this also gives you a full deployment history with one-click rollback to any previous artifact. On more than one occasion, that rollback button has turned a broken deployment into a thirty-second fix instead of a full-blown panic.

Why this is usually enough

This pipeline doesn't run a linter. It doesn't build a Docker image. It doesn't talk to Kubernetes. For a massive chunk of small projects, that is perfectly fine. Those things can be added later if the project actually grows into needing them.

What this pipeline does is permanently eliminate the three places where manual deploys fail: someone forgets to install dependencies, someone deploys untested code, or someone deploys the wrong branch. A .gitlab-ci.yml of this size catches all three. Plus, it is short enough that the next engineer to touch the project can read and understand the entire deployment flow in under a minute.

Advertisement

Need help with this?

If you'd rather have someone handle Containerization & Automation for you, that's exactly what I do.

Get in Touch
PreviousBash Scripts I Reuse on Every New ServerNext The Ansible Playbook I Run on Every New Server