A GitLab CI/CD Pipeline That Covers Most Small Projects
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.