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