It usually starts the same way: the code lives on GitHub or GitLab.com, someone builds the app on their laptop, and releases go out via git pull over SSH. With one developer, that works. Then a second and third join, a test server appears, and nobody can say for sure which version is running in production or who put it there.

The other half of the problem is depending on someone else’s service. CI minute limits, per-seat pricing that grows with the team, container images sitting in a third-party registry. And some companies simply have to keep code and artifacts on their own infrastructure.

We set up your own GitLab, build the pipeline and split the environments so that production only receives what has passed the build, the tests and a human sign-off.

What we do

  • GitLab CE on your server. A dedicated VM on Proxmox or a bare-metal server at OVH or Hetzner. Omnibus package, HTTPS, mail, SSH-key login, 2FA for everyone.
  • Container Registry. We enable the built-in image registry. The runner builds an image and pushes it there; servers pull it with a deploy token that can only read the registry.
  • Runners. Docker executor for builds and tests, every job in a clean container. A separate production runner that only picks up protected branches and tags. Shell executor where a container gets in the way, e.g. Ansible deploys.
  • Build → test → deploy pipeline. Build the image, run the tests, then deploy: a dedicated deploy user logs into the server, pulls the right image tag and restarts docker compose.
  • Dev, stage and prod environments. Each has its own variables, its own server and its own deployment history in GitLab. Dev updates on every commit to the working branch, stage from main, prod from a release tag.
  • Review apps (optional). A temporary copy of the app for each branch on its own subdomain, torn down after the merge.

Keeping production safe from accidents

The rule is simple: you can’t push to prod “from a branch, just to see”. In .gitlab-ci.yml it looks like this:

deploy_prod:
  stage: deploy
  tags: [prod]                 # production runner only
  environment:
    name: production
  resource_group: production   # no two deploys at once
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: manual             # a human presses the button
  script:
    - ssh deploy@$PROD_HOST "cd /opt/app && IMAGE_TAG=$CI_COMMIT_TAG docker compose pull && docker compose up -d"

On top of that: protected branches and tags, so only maintainers can create v* tags. The production environment is protected too, so only a small group sees the deploy button. The production runner is registered as protected and won’t run anyone else’s jobs.

Secrets live in CI/CD variables, not in the repository. We mark them masked (kept out of job logs) and protected (available only on protected branches), and scope them to environments: DATABASE_URL differs between stage and prod, and a job from a dev branch never sees the production password.

Backups, upgrades and the gotchas

Backups. gitlab-backup create on a schedule, with the archive copied off the server right away. First trap: the archive doesn’t include gitlab.rb or gitlab-secrets.json. Without the latter, CI variables and tokens can’t be decrypted after a restore. So we store them separately and run a test restore on a clean machine at least once. A backup can only be restored onto the same GitLab version.

Upgrades. GitLab ships monthly and needs regular upgrades, because security fixes keep coming. You can’t jump from an old version to the latest in one go: there are required upgrade stops (the upgrade path), and after each one you wait for background migrations to finish. We snapshot the VM before upgrading. After gitlab-ctl reconfigure we check that every service came up — Sidekiq sometimes fails to start the first time and needs a manual restart.

Monitoring. GitLab exposes Prometheus metrics. We watch memory, disk (the registry and artifacts grow fast, so we set a cleanup policy for old images), Sidekiq queues and runner health. Alerts go to Grafana or your chat.

Honest note on resources. GitLab is heavy. With 4 GB of RAM it only starts in a stripped-down configuration and feels sluggish. For a small team, plan for at least 8 GB and 4 cores, plus separate resources for runners — builds shouldn’t run on the same machine.

When you don’t need GitLab. Two developers, one project, simple CI? Look at Gitea or Forgejo. They run in a few hundred megabytes of memory and come with their own registry and GitHub Actions–compatible CI. If GitLab is too much for you, we’ll say so.

What you get

  • GitLab on your server, with admin access for you, not just us.
  • A Container Registry with a cleanup policy for old images.
  • Runners: shared ones for builds and a separate, protected one for production.
  • A working .gitlab-ci.yml with build → test → deploy stages and a template for new projects.
  • Dev, stage and prod environments with separate variables, protected branches and manual release approval.
  • GitLab backups including config files, with a tested restore.
  • Monitoring and alerts for memory, disk, queues and runners.
  • Documentation: adding a project, cutting a release, rolling back, upgrading GitLab.

If you’re still deploying “by hand over SSH”, or your hosted CI bill is growing faster than your team, get in touch. Tell us how many people and projects you have, and we’ll suggest what to run: GitLab or something lighter.