cubeshipd/cubeship-gitlab-template
Gitlab
GitLab CE: self-hosted Git, merge requests and CI/CD, over HTTPS
GitLab on Cubeship
GitLab Community Edition is a self-hosted DevOps platform: Git repositories, merge requests, issues, wikis, a package registry and CI/CD pipelines, in one Linux package.
This template installs it on a Cubeship instance, with a managed Redis and volumes for the repositories, the database and its configuration. The Postgres is the one inside the image, and it has to be — see Why the database is not a managed one.
GitLab is the heaviest thing you can put on an instance. Upstream's baseline for a single machine is 8 vCPU and 16 GB of memory; this template ships upstream's memory-constrained settings instead, and asks the machine for 4 CPU and 4 GiB. Give it a VPS with at least 8 GB of memory and 40 GB of disk free, and expect a handful of developers, not a hundred.
What it creates
- gitlab — GitLab CE, from
gitlab/gitlab-ce:19.3.2-ce.0, answering on the domain you choose, with three volumes:/etc/gitlab—gitlab.rbandgitlab-secrets.json, the keys every encrypted column in the database is readable with./var/opt/gitlab— every repository, LFS object, upload, artifact and package, and the Postgres database./var/log/gitlab— the logs, kept because they are where a failed start explains itself.logrotateinside the image bounds them.
- gitlab-redis — a managed Redis 7.4, holding sessions and the background queues. The Redis inside the image is turned off.
It needs Cubeship 0.7.2 or newer.
What you are asked
| Input | What to give |
|---|---|
| Where GitLab answers | A domain you control, pointed at your instance. It becomes external_url, which GitLab writes into every clone URL, webhook and email. |
| The password for the root account | Nothing — the instance generates it and shows it once. Keep a copy; it is read only on the first start, while GitLab creates root. |
| The port Git over SSH answers on | 2222 by default, from 1024 to 65535. Not 22, which is the server's own SSH. |
Why the database is not a managed one
GitLab loads its schema with psql --single-transaction, which takes a lock per
partitioned table and needs max_locks_per_transaction far above Postgres's
default of 64. Omnibus sets 128 for its own. A managed Postgres on Cubeship
runs the official image's defaults and takes no server parameters, so the very
first migration ends in:
ERROR: out of shared memory
HINT: You might need to increase "max_locks_per_transaction".
and no reconfigure ever finishes. Nothing in the template can raise it, so the
bundled Postgres runs instead, with its data in the /var/opt/gitlab volume.
That is also why gitlab-backup below, and not a Cubeship database dump.
The first start takes about ten minutes
GitLab reconfigures itself and runs its database migrations before anything answers, and on a small machine that is five to fifteen minutes. The domain returns 502 or 503 the whole time, and the deploy itself is reported as finished long before. Watch it get there:
cubeship app logs gitlab/production/gitlab --follow
It is up when the sign-in page answers 200:
curl -s -o /dev/null -w '%{http_code}\n' https://<your domain>/users/sign_in
/-/health is no use for this from outside: GitLab answers it only to
localhost and returns 404 to everyone else, even once it is running.
Then sign in as root with the password above.
Close sign-ups
Anyone who finds the domain can register an account until you stop them. Do it first: Admin → Settings → General → Sign-up restrictions, clear Sign-up enabled, and save. Add people afterwards under Admin → Users.
If you lose the root password, reset it from the machine the app runs on — Cubeship has no console into an app:
docker exec -it $(docker ps -qf name=cubeship-gitlab-production-gitlab) \
gitlab-rake "gitlab:password:reset[root]"
Git over SSH and HTTPS
Both work. Over SSH, add your key under Settings → SSH keys and clone with the URL GitLab shows, which carries the port:
git clone ssh://git@<your domain>:2222/<group>/<project>.git
SSH is published on the port you answered — 2222 unless you chose
another — because 22 is the server's own SSH. If your provider has a
firewall in front of the machine, open that port there as well; Cubeship
opens it in the server's own firewall. Nothing proxies it: it is GitLab's
sshd, directly.
Over HTTPS, clone https://<your domain>/<group>/<project>.git and push
with your password or a personal access token from
Settings → Access tokens — required once two-factor sign-in is on. Git
LFS works over the same URL.
What is turned off, and why
| Off | Why |
|---|---|
| The container registry | It needs a second domain and a port of its own; an app on Cubeship has one. Use the instance's own registry, or push elsewhere. |
| Prometheus, Alertmanager, the exporters, KAS | Around 300 MB of memory for monitoring this template does not read. Cubeship charts the container's CPU and memory itself. |
| Puma's cluster mode | worker_processes = 0 runs one Puma process. It is the single largest saving upstream lists, and the reason 4 GiB is enough. |
| Let's Encrypt | Traefik holds the certificate; nginx inside the image serves plain HTTP on port 80. |
Runners
No runner is included, so pipelines stay queued. gitlab-runner starts a
container per job and needs the Docker socket, which Cubeship does not give an
app. Run it on another machine with Docker and register it against
https://<your domain> with a token from Admin → CI/CD → Runners — see
upstream's runner install
guide. To hide CI instead, clear
CI/CD under Admin → Settings → General → Visibility and access controls.
No SMTP is set up, and GitLab runs without it — but then nobody is notified of
a merge request and nobody can reset a password by email. To add it, edit
GITLAB_OMNIBUS_CONFIG on the gitlab app, appending:
gitlab_rails['smtp_enable'] = true;
gitlab_rails['smtp_address'] = 'smtp.example.com';
gitlab_rails['smtp_port'] = 587;
gitlab_rails['smtp_user_name'] = 'apikey';
gitlab_rails['smtp_password'] = 'the password';
gitlab_rails['smtp_domain'] = 'example.com';
gitlab_rails['smtp_authentication'] = 'login';
gitlab_rails['smtp_enable_starttls_auto'] = true;
gitlab_rails['gitlab_email_from'] = 'gitlab@example.com';
Then redeploy. Every statement is on one line, separated by ; — the variable
is one long Ruby string, and the whole of gitlab.rb can go in it.
Settings live in that one variable
GITLAB_OMNIBUS_CONFIG is written into /etc/gitlab/gitlab.rb on every start,
so a value there beats anything edited in the file by hand. Upstream's
configuration
reference is the list
of what may go in it. Changing the domain means changing external_url here
too — GitLab does not learn it from the request.
Resources
The app is limited to 4 CPU and 4 GiB — everything but Redis is inside it. If
the machine has more to spare, give Puma its workers back and GitLab gets several
requests at a time: set puma['worker_processes'] = 2 and raise the app's
memory to 6 GiB or more. If the container is killed and restarted under load,
that is the memory limit, not a crash.
shared_buffers and effective_cache_size are spelled out in
GITLAB_OMNIBUS_CONFIG because omnibus sizes Postgres from what it reads as the
machine's memory, and inside a container that is the host's. A
32 GB host would otherwise hand a 4 GiB container 8 GB of shared buffers, and
Postgres would not start.
Backups
Cubeship cannot dump this database: it is inside the app. Use GitLab's own backup, from the machine the app runs on:
docker exec -t $(docker ps -qf name=cubeship-gitlab-production-gitlab) \
gitlab-backup create
It writes a tar into /var/opt/gitlab/backups. It does not include
/etc/gitlab, and a backup restored without gitlab-secrets.json leaves
every encrypted value — two-factor secrets, CI variables, tokens — unreadable.
Copy that file too, and keep both off the machine.
Upgrading
Change tag and redeploy, but GitLab has required stops: skipping one
leaves migrations that cannot run. Check upstream's upgrade
path for the versions between
the one installed and the one wanted, and take them in order. The Postgres in
the volume is upgraded by the image itself, which is one fewer thing to line up
than an external one would be.
What this creates
gitlab
gitlab/gitlab-ce:19.3.2-ce.0
gitlab-redis
Redis 7.4
/etc/gitlab
Volume of gitlab
/var/opt/gitlab
Volume of gitlab
/var/log/gitlab
Volume of gitlab
TCP 22
Published by gitlab on ${input.sshPort}
# yaml-language-server: $schema=https://cubeship.dev/schema/template/v1.json
version: 1
# The first release that publishes an app's TCP ports.
minCubeship: "0.7.2"
project: gitlab
inputs:
- key: domain
type: domain
label: Where GitLab answers
help: It becomes external_url. Changing it later means changing GITLAB_OMNIBUS_CONFIG on the app.
- key: rootPassword
type: secret
label: The password for the root account
help: Read once, on the first start, while GitLab creates root. Keep a copy.
generate: 32
- key: sshPort
type: number
label: The port Git over SSH answers on
help: 22 is the server's own SSH, so Git gets another. Open it in your provider's firewall too.
default: 2222
min: 1024
max: 65535
# The database is the one inside the image, and it has to be. GitLab loads its
# schema with `psql --single-transaction`, which takes a lock per partition and
# needs max_locks_per_transaction well above Postgres's default of 64 —
# omnibus sets 128 for its own. A managed Postgres here runs the image's
# defaults and takes no server parameters, so the load ends in `out of shared
# memory` every time. Its data is in /var/opt/gitlab with the repositories.
databases:
- key: cache
name: gitlab-redis
# Sessions and the background queues. 7.0 is the minimum, 7.2 the
# recommendation.
engine: redis
version: "7.4"
apps:
- key: web
name: gitlab
image: gitlab/gitlab-ce
tag: "19.3.2-ce.0"
# nginx inside the image, with TLS left to Traefik.
port: 80
# A page Rails renders for anyone signed out, so it proves Rails is up.
# Not /-/health: like /-/readiness and /-/liveness it answers 404 to any
# address off the monitoring allowlist, which is localhost, so the proxy's
# probe never passes and the domain stays down. Widening the allowlist to
# the proxy's network would open them to every visitor too, since GitLab
# sees each request arrive from the proxy.
health: /users/sign_in
domains:
- host: ${input.domain}
# sshd inside the image, on the instance's address. GitLab writes this
# port into every SSH clone URL it shows.
tcp:
- port: 22
host: ${input.sshPort}
volumes:
# gitlab.rb and gitlab-secrets.json, which holds the keys every encrypted
# column in the database is readable with. Lose it and lose them.
- path: /etc/gitlab
# Repositories, LFS objects, uploads, artifacts, the package registry.
- path: /var/opt/gitlab
# Kept across deploys because it is where a failed reconfigure explains
# itself; logrotate inside the image bounds it.
- path: /var/log/gitlab
limits: { cpu: 4, memory: 4Gi }
env:
# One long Ruby string written into gitlab.rb on every start, so it always
# wins over the file in the volume. Most of it is upstream's
# memory-constrained set, which is what makes this fit on one VPS.
#
# shared_buffers and effective_cache_size are spelled out because omnibus
# sizes them from what it reads as the machine's memory, and inside a
# container that is the host's — a 32 GB host would hand a 4 GiB
# container 8 GB of shared buffers and Postgres would not start.
GITLAB_OMNIBUS_CONFIG: >-
external_url 'https://${input.domain}';
gitlab_rails['nginx'] = { 'listen_port' => 80, 'listen_https' => false, 'proxy_set_headers' => { 'X-Forwarded-Proto' => 'https', 'X-Forwarded-Ssl' => 'on' } };
gitlab_rails['gitlab_shell_ssh_port'] = ${input.sshPort};
letsencrypt['enable'] = false;
registry['enable'] = false;
postgresql['shared_buffers'] = '256MB';
postgresql['effective_cache_size'] = '1GB';
redis['enable'] = false;
gitlab_rails['redis_host'] = '${db.cache.host}';
gitlab_rails['redis_port'] = '${db.cache.port}'.to_i;
gitlab_rails['redis_password'] = '${db.cache.password}';
gitlab_rails['initial_root_password'] = '${input.rootPassword}';
gitlab_rails['display_initial_root_password'] = false;
puma['worker_processes'] = 0;
sidekiq['concurrency'] = 10;
prometheus_monitoring['enable'] = false;
gitlab_kas['enable'] = false;
gitlab_rails['env'] = { 'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000' };
gitaly['env'] = { 'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000', 'GITALY_COMMAND_SPAWN_MAX_PARALLEL' => '2' };
gitaly['configuration'] = { concurrency: [ { 'rpc' => '/gitaly.SmartHTTPService/PostReceivePack', 'max_per_repo' => 3 }, { 'rpc' => '/gitaly.SSHService/SSHUploadPack', 'max_per_repo' => 3 } ] };