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

Let's Talk

Interested in working together or have a question? I'm always open to discussing new projects.

Get in touch

Connect

Find me on social media and professional networks.

Privacy PolicyTerms of Conditions
© 2026 Irfan MiralDeveloped byirfanMiral.com
HomeAbout/ResumeBlogContact
2026-01-22• 5 min read

A GitLab CI/CD Pipeline That Covers Most Small Projects

DevOps GitLab CI/CD Automation

When I take over a small project that has no CI at all, deployment usually means someone SSHing in, 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, a missed environment variable. The fix isn't a complicated pipeline with a dozen stages. It's a short .gitlab-ci.yml that does the same three things every time: build, test, 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 checkouts fast on repos with long histories, and caching node_modules by branch means most pipelines skip a full reinstall. The build stage produces an artifact that the deploy stage reuses, so the code only gets built once per pipeline run, not once per stage.

Deploying over SSH

For small projects, a Kubernetes manifest or container registry is often more infrastructure than the project needs. Plain rsync over SSH, triggered from CI, 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 in a masked, protected CI/CD variable, never in the repo. ssh-keyscan populates known_hosts so the job doesn't hang on a host-key prompt, and rsync --delete keeps the remote directory in sync with the build output rather than slowly accumulating stale files.

Production gets a manual gate

Staging deploys on every push to develop. 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 button in GitLab's pipeline view rather than something that fires automatically. Combined with GitLab's environments, this also gives you a deploy history with one-click rollback to a previous artifact, which on more than one occasion has turned a bad deploy into a thirty-second fix instead of a panic.

Why this is usually enough

This pipeline doesn't run a linter, doesn't build a Docker image, doesn't talk to Kubernetes. For a lot of small projects, that's fine, those things can be added later if the project grows into needing them. What this pipeline does is remove the three places where manual deploys go wrong: someone forgets to install dependencies, someone deploys untested code, or someone deploys the wrong branch. A .gitlab-ci.yml this size catches all three, and it's short enough that the next person to touch the project can read the whole thing in under a minute.

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