Skip to content
GitRiverGitRiver
RU
Navigation

Installing and Configuring GitRiver

Installing GitRiver: a quick start with Docker Compose, deb and rpm packages, the Helm chart, a reverse proxy, upgrades and verifying the delivery

There are three installation methods: a Docker image (compose), a deb/rpm system package, and a Helm chart for Kubernetes. All three give you the same feature set — only the delivery method and the configuration location differ:

Method Configuration Upgrade Best for
Docker Compose ./data/gitriver/gitriver.toml, variables in compose docker compose pull && up -d evaluation, small installs, getting familiar with the product
deb/rpm package /etc/gitriver/gitriver.toml, systemd drop-in apt upgrade / dnf upgrade a distro machine, running without Docker
Helm chart values.yaml helm upgrade a Kubernetes cluster

Build machines are installed separately, as their own package — see Installing an External Runner.

Release Authenticity

Every release ships with SHA256SUMS (checksums of all files) and SHA256SUMS.asc — the maintainer’s signature over that file. An unsigned release is never published: without a signature there’s no way to tell the publisher’s release from one tampered with in transit.

# the publisher's key ships with the release; check the fingerprint against the one published on the website
gpg --import gitriver-release-key.asc
gpg --show-keys --with-fingerprint gitriver-release-key.asc   # same one as in gitriver-release-key.fingerprint
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS

The fingerprint also ships as a separate line — gitriver-release-key.fingerprint — and is included in the signed checksum list. You should still check it against the one published on the publisher’s website: the file next to the key only confirms that the two match each other.

Images don’t carry their own signature — their digests are part of the same signed list: image-digest.txt for the server, runner-image-digest.txt for the external runner. Verify after pulling an image:

docker pull gitriver/gitriver:1.1.0
docker image inspect --format '{{index .RepoDigests 0}}' gitriver/gitriver:1.1.0
cat image-digest.txt      # should match

What is checked is the manifest list digest — what the registry serves for the tag. With Docker’s classic image store, docker image inspect (RepoDigests) shows it too; with the containerd image store it may hold a single-architecture digest — take it from the registry instead: docker buildx imagetools inspect gitriver/gitriver:1.1.0.

Verify the signature before installing, not after: a checksum taken from the same place as the file it checks proves nothing.


Requirements

  • PostgreSQL 15+ with the pgcrypto extension available (the shipped docker-compose.prod.yml brings up 18; on the RHEL family the extension is in the postgresql-contrib package)
  • 2+ CPU, 4+ GB RAM (recommended)
  • Docker 20.10+ with BuildKit — for the image-based install and for the built-in CI runner (needs access to the daemon socket)

With the built-in runner the server maintains the build cache of the whole machine. Once a day (and once at startup) it keeps at most 10 GB of cache in EVERY BuildKit builder on the host — including builders it did not create and that have nothing to do with CI. Otherwise builder volumes grow without bound. If anything else is built on this machine, take that into account when choosing the host.

The server only needs Docker for the built-in CI runner. If jobs run on external runners, the server doesn’t need it at all — see Lightweight Installation Without Docker.


Quick Start (Docker Compose)

You need two files from the release — docker-compose.prod.yml and gitriver.env.example:

mkdir gitriver && cd gitriver
# put docker-compose.prod.yml and gitriver.env.example here
cp gitriver.env.example .env
${EDITOR:-nano} .env    # GITRIVER_DB_PASS — any password, GITRIVER_BASE_URL=http://localhost:3000
docker compose -f docker-compose.prod.yml up -d

The server is available at http://localhost:3000. On first visit it opens the setup wizard, which creates the administrator.

This is how you try the product on your own machine. For an installation reachable over the network, use the production installation instead: the setup wizard has no protection of its own, and exposed to the network it hands the installation to whoever reaches it first.


Production Installation

1. Preparation

mkdir -p /opt/gitriver/data/{gitriver,postgres,registry}
cd /opt/gitriver

Put docker-compose.prod.yml and the environment sample gitriver.env.example here — both ship with the release.

2. The .env File

Every setting for a production install goes into .env, next to the compose file — the compose file itself doesn’t need editing:

cp gitriver.env.example .env
${EDITOR:-nano} .env              # database password, external address

GITRIVER_DB_HOST, GITRIVER_DB_USER, GITRIVER_DB_PASS, GITRIVER_DB_NAME and GITRIVER_BASE_URL are required; everything else is documented in the sample itself.

Starting without .env stops with a list of the missing variables — that’s a safeguard, not an inconvenience. Without a database connection the server opens the initial setup wizard, and port 3000 is exposed: whoever opens the page first sets the database and creates the administrator. The same warning applies to the package install — see Configuration.

3. Start

docker compose -f docker-compose.prod.yml up -d

You can check that the environment was read and substituted correctly without starting the service:

docker compose -f docker-compose.prod.yml config

The distribution ships the publisher’s image, tagged with the version. To use your own mirror, set GITRIVER_IMAGE in .env. The tag is pinned to the version (not latest): the installation changes only when you change the tag yourself.

The registry service in this file is the image registry for CI jobs. It has no password, and port 5000 is published on all addresses because job containers need to pull images from it: close this port from the outside with firewall rules. If 5000 is already taken on the host, set another host port with GITRIVER_REGISTRY_PORT in .env — inside the container the port stays 5000, and the address jobs use is set separately (GITRIVER_CI_REGISTRY, below).

Jobs that docker push to the instance registry require GITRIVER_CI_REGISTRY in .env. Without it, the registry address (CI_REGISTRY in jobs) is derived from GITRIVER_BASE_URL, and that is the server’s port — no registry listens there: docker login fails, and docker push fails the job. Set it to an address the host’s Docker daemon can reach: both the runner and the job containers talk to that daemon through its socket, so the container alias (host.docker.internal) won’t do — the host’s external address with port 5000, e.g. 192.0.2.1:5000. The registry in this file has no TLS and speaks plain http, and the daemon needs an explicit list: add the registry address to insecure-registries in the Docker configuration.

4. Reverse Proxy (nginx)

server {
    listen 443 ssl http2;
    server_name git.example.com;

    ssl_certificate     /etc/ssl/certs/git.example.com.pem;
    ssl_certificate_key /etc/ssl/private/git.example.com.key;

    client_max_body_size 512m;  # For LFS and large pushes

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE (CI logs, pipeline events)
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Installing from a Package (deb/rpm)

The package installs the binary (the web interface is built into it), a systemd unit file, and creates the gitriver user and data directories. Docker is not required for this — provided CI jobs run on external runners (see Lightweight Installation Without Docker).

The package needs glibc 2.34 or newer: it installs on RHEL, Rocky and AlmaLinux 9+, Debian 12+, Ubuntu 22.04+, Astra Linux 1.8+, and other distributions no older than those. For older ones, the image-based install remains the option.

There are two architectures — x86-64 and arm64: both sets of packages ship with every release (_amd64.deb / .x86_64.rpm and _arm64.deb / .aarch64.rpm), and the images are built as a manifest list, so docker pull picks the right one on its own.

1. Installation

# Debian, Ubuntu, Astra
sudo apt install ./gitriver_1.1.0-1_amd64.deb

# RHEL, Rocky, AlmaLinux, RED OS
sudo dnf install ./gitriver-1.1.0-1.x86_64.rpm

The packages ship with the release: download them from the release page along with SHA256SUMS and its signature, and verify them before installing — see the “Release Authenticity” section above for how.

2. Database

RHEL, Rocky, AlmaLinux, RED OS: enable a PostgreSQL stream of 15 or newer first. The default stream on these systems is 13, while the package recommends postgresql-server >= 15: dnf silently skips a recommendation it cannot satisfy, so no database gets installed at all.

sudo dnf module enable -y postgresql:16
sudo dnf install -y postgresql-server postgresql-contrib   # contrib provides pgcrypto
sudo postgresql-setup --initdb
# password login: the default here is ident, and the server would get "Ident authentication failed"
sudo sed -i -E 's/^(host\s.*)\bident$/\1scram-sha-256/' /var/lib/pgsql/data/pg_hba.conf
sudo systemctl enable --now postgresql

The server will not refuse to run on an older database, but it warns at startup: versions below 15 are not tested.

sudo -u postgres createuser gitriver --pwprompt
sudo -u postgres createdb  gitriver --owner gitriver

3. Configuration

The connection string goes into /etc/gitriver/gitriver.toml:

database_url = "postgres://gitriver:password@localhost:5432/gitriver"
base_url = "https://git.example.com"

You can also leave database_url empty: the server then brings up the initial setup wizard and writes the connection string itself. The wizard is reachable by anyone who can reach the port — on a machine with an external address, keep the port closed until setup is done.

Anything that shouldn’t land in the file the wizard edits (passwords, tokens, environment overrides) goes into a systemd drop-in instead:

sudo systemctl edit gitriver
[Service]
Environment=GITRIVER_BASE_URL=https://git.example.com
Environment=GITRIVER_METRICS_TOKEN=long-random-string

Environment variables take priority over the TOML file.

4. Start

sudo systemctl enable --now gitriver
sudo systemctl status gitriver
journalctl -u gitriver -f

The service is deliberately not started right after the package installs: the setup wizard shouldn’t be open to the network until the database is set or the port is closed.

What’s Installed Where

Path What it is
/usr/bin/gitriver the server with the built-in web interface and its console commands (run, backup, restore, serv, notices, init-config)
/etc/gitriver/gitriver.toml settings; not overwritten on package upgrade
/etc/gitriver/.jwt_secret the token signing key, created on install
/var/lib/gitriver/ repositories, CI data, Pages, registry
/usr/lib/systemd/system/gitriver.service the unit file
/usr/lib/systemd/system/gitriver.slice the CI resource slice (its limits are set separately, see “CI Job Resource Limits”)
/usr/share/doc/gitriver/ the full configuration example, documentation, the license agreement, and the third-party license list

The /etc/gitriver directory is writable by the service — the setup wizard writes the connection string into the file there. Keys the server creates itself without an explicit path (commit signing, the SSH host key) default to sitting next to the settings file, in /etc/gitriver. The token signing key is created during package install and survives upgrades; changing it would sign every user out.

Licenses

The terms for GitRiver itself are in /usr/share/doc/gitriver/LICENSE.en.md (and LICENSE.ru.md). Third-party open-source code included in the distribution is listed with its terms in /usr/share/doc/gitriver/THIRD-PARTY-NOTICES.md: roughly five hundred Rust libraries (crates.io) and nearly as many npm packages, with the text of their licenses. The list matches the contents of this particular delivery.

The same file is also printed by the server itself — gitriver notices — and shown in the web interface on the /legal page, “Third-Party Components” tab: the terms can be read in an air-gapped environment too, without internet access. In the container image the file lives at the same path: /usr/share/doc/gitriver/THIRD-PARTY-NOTICES.md.

SSH

The built-in SSH server is turned on with the [ssh_server] section in the config. The unit file grants the service permission to bind ports below 1024 (CAP_NET_BIND_SERVICE), so it can take port 22 — but that port is usually already taken by the system’s sshd, in which case give it its own port:

ssh_port = 2222

[ssh_server]
listen = "0.0.0.0:2222"
host_key_path = "/var/lib/gitriver/ssh_host_ed25519_key"

The server serves at most 256 concurrent SSH connections. A connection beyond the limit is closed immediately; the client sees the drop and retries, and the rejection is logged as “встроенный SSH: соединение отклонено — предел одновременных исчерпан” (“built-in SSH: connection rejected — concurrent connection limit exhausted”).

Alternative — the system’s sshd: the server still manages users’ authorized_keys itself; the service account’s home directory is /var/lib/gitriver:

authorized_keys_path = "/var/lib/gitriver/.ssh/authorized_keys"

CI Job Resource Limits

The line Delegate=cpu pids memory is already in the unit file and is needed: without it, jobs without image: run with no memory or process-count limits. The same file also sets Slice=gitriver.slice: the slice all CI usage is metered against. Its limit is set separately — it doesn’t come from the package or from gitriver.toml:

systemctl set-property gitriver.slice CPUQuota=600% MemoryMax=12G

More detail: Job Resource Limits.

Upgrading

sudo apt install ./gitriver_NEW-VERSION_amd64.deb   # or dnf upgrade

The package restarts the service itself if it was running; the server upgrades the database schema itself at startup. Settings, data, and the token signing key are left in place.

Removal

apt remove / dnf remove stops the service and removes the distribution’s files. Repositories under /var/lib/gitriver and the database are untouched either on removal or on apt purge — purge only removes the settings and the token signing key created at install time. Wiping the data remains a separate decision for the administrator to make.

dnf remove has no concept of purge: it saves an edited config as /etc/gitriver/gitriver.toml.rpmsave, and the token signing key is left in place — after reinstalling, previously issued tokens keep being accepted.


Installing an External Runner (deb/rpm)

An external runner is a separate gitriver-runner package, installed on different machines — the ones that run builds. The server doesn’t need it, and a build machine doesn’t need the server, which is why the packages are separate. The two coexist fine on the same machine: their paths, accounts, and services don’t overlap.

The same release also provides a runner image — the same registry repository as the server image, under its own tag (<server image>:<version>-runner); it’s used to run the runner in a container, and by autoscaling on Kubernetes as well.

1. Installation

# Debian, Ubuntu, Astra
sudo apt install ./gitriver-runner_1.1.0-1_amd64.deb

# RHEL, Rocky, AlmaLinux, RED OS
sudo dnf install ./gitriver-runner-1.1.0-1.x86_64.rpm

For arm64, use gitriver-runner_1.1.0-1_arm64.deb and gitriver-runner-1.1.0-1.aarch64.rpm from the same release.

The runner packages ship with the release next to the server packages, and are verified with the same SHA256SUMS file.

The package pulls in Git, Git LFS and the SSH client as dependencies. Docker is optional and installed separately, from any source (docker.io, docker-ce, moby-engine): only jobs with image: need it.

The service doesn’t start on install: without a server address and a token it has nothing to do. The package lists the next steps at the end of the install — they are steps 2–5 below.

2. Runner Token

A runner is registered on the server — “Administration” → “Runners” → “Add”. The token (grr_…) is shown once; the same screen sets the labels that route jobs to this specific machine (runs-on:), and its scope — server-wide, a group, or a single repository.

3. Configuration

The server address and token go into /etc/gitriver-runner/runner.env:

GITRIVER_URL=https://git.example.com
GITRIVER_RUNNER_TOKEN=grr_...

Give the address with https://. The runner works over http://, but warns at startup: the token and job secrets would travel over the network in plain text.

The rest of the parameters in the same file are commented out along with explanations and defaults (working directory, server polling interval, artifact limits, git operation timeouts, the image used to clean up working copies, the resource slice); more detail is in the administrator guide. The file is read by the service and locked down from everyone else (0640, group gitriver-runner): the token unlocks the job queue, and with it, build secrets. Don’t make the file world-readable.

Changes to the file take effect after a service restart: sudo systemctl restart gitriver-runner.

4. Docker Access

The runner executes jobs with image: in containers and talks to the Docker daemon on its own machine. The package doesn’t grant this access: membership in the docker group is equivalent to root on the machine, and granting it is a decision for the administrator, not for the installer.

sudo systemctl edit gitriver-runner
[Service]
SupplementaryGroups=docker

If the service is already running, restart it: sudo systemctl restart gitriver-runner. Without this access, only jobs without image: run — directly on the machine, with the gitriver-runner account’s permissions.

5. Start

sudo systemctl enable --now gitriver-runner
journalctl -u gitriver-runner -f

The log at startup shows which server the runner connected to and which directory it claimed. It also appears in the server’s admin panel (“Runners”), with its labels and the time of its last contact.

If the runner doesn’t show up in the admin panel, the reason is in the service log: most often a wrong server address, a server unreachable from this machine, or an invalid token (for example, the runner was deleted on the server). Fix runner.env and restart the service.

What’s Installed Where

Path What it is
/usr/bin/gitriver-runner the runner
/etc/gitriver-runner/runner.env server address, token, and parameters; not overwritten on package upgrade
/var/lib/gitriver-runner/ job working copies, artifact and cache archives
/usr/lib/systemd/system/gitriver-runner.service the unit file
/usr/share/doc/gitriver-runner/ documentation, the license agreement, and the third-party license list

The working directory is created by systemd (StateDirectory) and handed to the runner entirely, with 0700 permissions: it holds job working copies along with their credentials. The runner rejects a directory owned by another user and doesn’t start — see Runner Working Directory.

Running the Runner in a Container

docker run -d --name gitriver-runner \
  -e GITRIVER_URL=https://git.example.com \
  -e GITRIVER_RUNNER_TOKEN=grr_... \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitriver/gitriver:1.1.0-runner

The daemon socket is mounted in for jobs with image: — job containers come up on the host, next to the runner itself, not inside it. The working directory mounted from the host (-v) must be owned by the user the runner runs as inside the container (root), or it won’t start.

Upgrading and Removal

sudo apt install ./gitriver-runner_NEW-VERSION_amd64.deb   # or dnf upgrade

The package restarts the service itself if it was running. Settings and the working directory are left in place; apt remove stops the service and removes the distribution’s files, and purge also removes the settings. Neither touches the working directory: it may hold working copies of unfinished jobs.

dnf remove has no notion of purge: an edited runner.env is saved as /etc/gitriver-runner/runner.env.rpmsave — the token stays on disk, and after reinstalling it is accepted again.


Installing on Kubernetes (Helm)

The chart deploys a Deployment, a Service, optionally an Ingress, a persistent volume for repositories, and a Secret with the settings. The database is external by default.

Installation

The chart ships with the release as the gitriver-1.1.0.tgz archive:

helm install gitriver gitriver-1.1.0.tgz \
  --namespace gitriver --create-namespace \
  --set database.host=postgres.internal \
  --set database.password=<PASSWORD> \
  --set config.baseUrl=https://git.example.com \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set ingress.hosts[0].host=git.example.com

The same values are easier to keep in a file of your own:

helm install gitriver gitriver-1.1.0.tgz -f values.yaml

Keep the password in your own Secret rather than on the command line:

kubectl -n gitriver create secret generic gitriver-db --from-literal=password=<PASSWORD>
helm install gitriver gitriver-1.1.0.tgz --set database.existingSecret=gitriver-db

Token Signing Key

This key signs every issued token, and some stored secrets depend on it too: two-factor one-time codes, OAuth application secrets, registry proxy tokens. Change the key and everyone is signed out, and those secrets stop being readable (more detail: Token Errors After Restart).

The chart passes the key to the server as the GITRIVER_JWT_SECRET environment variable, sourced from a Secret; it is never written into gitriver.toml. Where the value comes from:

  • config.existingJwtSecret.name — from an existing Secret (the key inside it is jwt-secret by default). The only option suitable for GitOps;
  • config.jwtSecret — the key as a plain value (ends up in the <release>-gitriver-signing-key Secret as the plain-text value from values);
  • neither set — the chart generates a key on first install, stores it in the <release>-gitriver-signing-key Secret, and carries the existing value forward on helm upgrade. The Secret is marked helm.sh/resource-policy: keep, like the data volumes: it survives helm uninstall, and a reinstall under the same release name picks it up.

Chart object names are built as <release>-gitriver, but if the release name already contains gitriver, the prefix is not repeated. With an install like the examples in this section (helm install gitriver …) the Secret is named gitriver-signing-key, not gitriver-gitriver-signing-key; the same applies to the volumes below.

Setting both at once is refused: the install stops with an explanation.

The third option doesn’t work under GitOps: Argo CD and Flux render the chart without a cluster (helm template), the existing key isn’t visible there, and every sync would mint a new one. The chart stops such a render with an explanation. Under GitOps, create the Secret once and reference it:

kubectl -n gitriver create secret generic gitriver-jwt \
  --from-literal=jwt-secret="$(openssl rand -base64 48)"
helm install gitriver gitriver-1.1.0.tgz --set config.existingJwtSecret.name=gitriver-jwt

A change to this Secret’s value only reaches the server after the pods restart: environment variables are read once, at startup.

Key Values

Value Default What it sets
image.repository, image.tag gitriver/gitriver, chart version the release image; point it at your own mirror here
database.host, .port, .user, .name — , 5432, gitriver, gitriver the external database
database.password / .existingSecret — the password directly, or an existing Secret
postgresql.enabled false bring up a database alongside the release (for evaluation)
config.baseUrl — the installation’s external address
config.ciLocalExecutor false the built-in CI runner (needs the node’s Docker socket)
config.ssh.enabled false the built-in SSH server and the ssh port on the Service
service.ssh.port, .nodePort 22, — the SSH port on the Service; how users reach it from outside is decided by service.type — LoadBalancer, NodePort (the node port, without it the install stops — see “What the Chart Validates”), ClusterIP — inside the cluster only
config.ssh.containerPort, .publicPort, .publicHost 2222, — , — the SSH port inside the pod; the port and host in SSH clone URLs (empty — the port from the Service, the host from config.baseUrl)
config.existingJwtSecret.name, .key — , jwt-secret an existing Secret holding the token signing key (required under GitOps)
config.jwtSecret — the signing key as a plain value; empty — the chart generates one on install
config.extraToml — the rest of the TOML parameters: SMTP, LDAP, S3
config.extraEnv [] GITRIVER_* variables, including ones sourced from a Secret
persistence.size, .storageClass, .accessModes 50Gi, — , ReadWriteOnce the data volume
ingress.* disabled exposing the service externally
ingress.nginxDefaults true the chart’s four nginx annotations; your own annotations override the same keys
resources 500m/1Gi request, 4Gi memory limit the node’s share reserved for the server (no CPU limit, deliberately)
podSecurityContext, securityContext RuntimeDefault, all capabilities dropped except four pod permissions
tests.image — (the server image) the image used for helm test
replicaCount 1 number of replicas (see below)

Everything else: helm show values gitriver-1.1.0.tgz.

With an nginx Ingress class (“nginx” in className), the chart adds four annotations without which git doesn’t work through ingress-nginx: the controller rejects any request body over 1 MB — 413 Request Entity Too Large on a push — and closes a proxied request after 60 seconds — cloning a large repository breaks off. The chart lifts the body limit (proxy-body-size: "0": the server enforces its own upload limits), turns request buffering off (proxy-request-buffering: "off": a large push streams to the server instead of filling the controller’s disk) and sets the read and write timeouts to an hour (proxy-read-timeout, proxy-send-timeout). Your own annotations (ingress.annotations) override the same keys. With another controller, or className empty with nginx as the cluster’s default class — set the equivalents in annotations yourself.

What the Chart Validates Before Installing

The install stops with an explanation instead of leaving a pod in a restart loop when: no database connection is set; no password is set (for either an external database or the release’s own database); more than one replica is requested on a volume that isn’t ReadWriteMany or doesn’t exist at all; the token signing key is set twice — both as a value and as an existing Secret; SSH is enabled with a NodePort Service and no service.ssh.nodePort (30000-32767; and no config.ssh.publicPort) — a node port Kubernetes picks at random cannot be named in SSH clone URLs; the token signing key isn’t set and the render is running without a cluster — the existing value can’t be read from there, and every GitOps sync would mint a new key.

Pod Resources and Permissions

Resource requests match the installation requirements: cpu: 500m, memory: 1Gi. Only memory has a limit (4Gi) — it protects the node from a pod that has grown out of control. There is no CPU limit: with one, the server would slow down on load bursts (walking a large repository, the schema upgrade on first start), and the request already secures the server’s share of CPU on a busy node. Set your own values through resources in values.yaml.

The pod starts as root and switches to the service user on its own: it fixes the volume’s ownership and joins the Docker socket group for the built-in CI runner. Because of that, runAsNonRoot isn’t possible for it, and its permissions are narrowed a different way — every Linux capability is dropped except CHOWN, DAC_OVERRIDE, SETGID and SETUID, seccompProfile: RuntimeDefault is added, and privilege escalation is disabled. A cluster enforcing the restricted policy will reject such a pod — set your own exception for that namespace there.

The release check (helm test) runs on the server’s own image — already present in an air-gapped environment. To use your own image (it only needs wget), set tests.image.

Multiple Replicas

The server is designed to run as multiple replicas, but they need shared storage: a volume with accessModes: [ReadWriteMany] and strategy.type: RollingUpdate. More detail: Multiple Replicas. On ReadWriteOnce, keep a single replica: the default strategy (Recreate) exists precisely for this case — the volume isn’t handed to two pods at once.

CI in the Cluster

The built-in CI runner runs jobs in Docker on the server’s own machine, so it’s disabled in the cluster: external runners pick up the queue instead. You need at least one runner labeled default — otherwise jobs wait until ci_runner_max_wait_secs and then fail. Installing runners is covered in the CI guide.

Database Bundled with the Release

For evaluation, the chart can bring up a database itself — a single StatefulSet on the official postgres image:

helm install gitriver gitriver-1.1.0.tgz \
  --set postgresql.enabled=true \
  --set postgresql.auth.password=<PASSWORD>

The chart has no external dependencies: helm dependency update isn’t needed, and in an air-gapped environment the gitriver and postgres images in your internal registry are all it takes.

For production, keep the database outside the chart (database.host) or under a PostgreSQL operator. The release’s bundled database isn’t replicated, isn’t backed up, and doesn’t carry over across PostgreSQL major versions: bumping postgresql.image to the next major release will leave behind a volume the new server can’t read.

Upgrading

helm upgrade gitriver gitriver-NEW-VERSION.tgz --reuse-values --set image.tag=NEW-VERSION

The server upgrades the database schema itself at startup. The chart carries the token signing key over from the previous Secret — issued tokens remain valid.

Uninstalling the Release and Data

helm uninstall removes the release’s objects but LEAVES THE VOLUMES — that’s a safeguard against accidental data loss. After removal, the namespace still holds:

Volume What’s in it
<release>-gitriver repositories, registry, build artifacts, Pages
data-<release>-gitriver-postgresql-0 the database, if it was deployed alongside the release (postgresql.enabled=true)

Besides the volumes, the <release>-gitriver-signing-key Secret — the token signing key — is left behind too; it is marked helm.sh/resource-policy: keep like the volumes, and a reinstall under the same release name picks it up.

Two consequences follow from this, and both are worth knowing in advance.

Space is NOT freed: as long as the volumes exist, the space they use stays used, and on a shared cluster that goes unnoticed for a while. Check what’s left:

kubectl -n NAMESPACE get pvc

Deleting the namespace DELETES THE DATA. The database goes with the volumes, and with it the installation record the license activation is tied to: there’s nothing left to restore it from, and a new activation from the publisher will be needed. Before cleaning up “for tidiness,” take a backup (gitriver backup) and confirm it restores.

A deliberate full wipe, after a backup:

helm uninstall RELEASE -n NAMESPACE
kubectl -n NAMESPACE delete pvc <release>-gitriver data-<release>-gitriver-postgresql-0
kubectl -n NAMESPACE delete secret <release>-gitriver-signing-key

For a release named gitriver the names have no repeated prefix: volumes gitriver and data-gitriver-postgresql-0, Secret gitriver-signing-key.


Configuration

GitRiver is configured through a TOML file and/or GITRIVER_* environment variables. Environment variables take priority over the TOML file. Where the file lives depends on the installation method: /var/lib/gitriver/gitriver.toml in the image, /etc/gitriver/gitriver.toml from the package; on Kubernetes the chart assembles it from values.yaml (add your own settings through config.extraToml).

Generating the Configuration

gitriver init-config --output gitriver.toml

The sample is written in the terminal’s language (LANG, for example LANG=en_US.UTF-8 for English); the same sample in every language ships in the package — /usr/share/doc/gitriver/<language>/gitriver.toml.example.

The sample names every setting the installation has: the ones you don’t need are left in it commented out, along with an explanation and the default value. Lines marked “SECURITY” in the sample change access boundaries — read them in full before setting a value. The sample is exhaustive: a setting that isn’t listed there doesn’t exist in the product either.

Core Parameters

Parameter Env Default Description
host GITRIVER_HOST 0.0.0.0 Bind address
port GITRIVER_PORT 8080 HTTP port
database_url GITRIVER_DATABASE_URL — PostgreSQL URL
jwt_secret GITRIVER_JWT_SECRET auto Token signing key (32+ bytes)
git_repos_path GITRIVER_GIT_REPOS_PATH /var/lib/gitriver/repos Repository directory
web_dist_path GITRIVER_WEB_DIST_PATH — Directory with a different build of the interface. Not set — the interface built into the binary is served
base_url GITRIVER_BASE_URL http://{host}:{port} External URL
language GITRIVER_LANGUAGE ru Instance language: the server answers in it when the reader’s language is unknown — a request without an Accept-Language header, SSH, the CI job log, stored texts. A request with Accept-Language gets its own language if it is available (ru, en). An unavailable language is refused at startup
trusted_proxies — private networks Reverse proxy addresses whose X-Real-IP/X-Forwarded-For can be trusted. An empty list, or one with no entry that parses, means the same as unset: private networks are trusted (loopback, RFC1918, fc00::/7). This setting has no way to express “trust no one”
rate_limit_exempt_cidrs GITRIVER_RATE_LIMIT_EXEMPT_CIDRS empty Addresses exempt from rate limits (for example, a monitoring system or your own load-testing environment). Lifts every rate limit for the listed addresses, including the sign-in attempt limit — keep the list narrow. It does NOT lift the failed-password counter (five per name/address pair): that’s counted separately. More detail: admin-guide
pages_data_path GITRIVER_PAGES_DATA_PATH {repos}/../pages-data Where Pages static sites are stored

Access and Visibility

Parameter Env Default Description
public_profiles GITRIVER_PUBLIC_PROFILES false Whether user profiles are visible without signing in. Off by default: an anonymously readable staff list with email addresses is company information it never intended to publish. Applies the same way to /api/v1 and to the compatibility layers for other platforms’ interfaces
cors_origins — base_url only Origins allowed to make browser requests (CORS). Unset or an empty list — exactly one origin is allowed, base_url. Set this when the web interface lives at a different address than the API. Write it with the scheme (https://git.example.com) — the way a browser sends the Origin header; a value without a scheme is accepted but never matches any request
metrics_token GITRIVER_METRICS_TOKEN not set Access token for /metrics. Set — the endpoint requires an Authorization: Bearer <token> header; unset — it’s open. Metrics don’t name repositories or people, but the installation’s summary counters (how many users, repositories, security findings) are information about the organization’s activity. On an installation reachable from the internet, this token should be set

Database Connection (Alternative)

Instead of database_url you can specify the individual parameters:

GITRIVER_DB_HOST=localhost
GITRIVER_DB_PORT=5432
GITRIVER_DB_USER=gitriver
GITRIVER_DB_PASS=secret
GITRIVER_DB_NAME=gitriver

File Upload Limits

How much the server accepts in a single request:

What’s uploaded Limit Failure on excess
A package file — PyPI, Cargo, Maven, NuGet, Generic 500 MiB 413, the message names the declared size and the limit
npm publish (npm publish) 64 MiB for the whole request 413
Release attachment 500 MiB 413; over the owner’s quota — 403, see release attachment quota
Pages static site ZIP 500 MiB compressed, 1 GiB unpacked 413
Git LFS object 5 GiB 422; over the owner’s quota — 403
Image layer in the image registry 10 GiB for the whole layer (not per request) 413; over quota — 403, see package storage quota
CI job artifact and cache archive 500 MiB 413; over the owner’s quota — 403
git push over HTTP 500 MiB per request (compressed body) 413; unpacked beyond 500 MiB — 400

The npm limit is lower than the rest: an npm publish passes through the server’s memory in full. A 64 MiB request is roughly a 48 MiB package tarball. Every other upload streams straight to disk, and the server’s memory use doesn’t depend on file size.

The image layer limit is higher than the rest: base image layers (machine-learning environments, CUDA) run into the gigabytes. It’s counted per whole layer, however many requests it takes to transfer. How much space a layer actually gets is decided by the quota; the limit applies even where no quota is set.

These values are fixed in the product itself and aren’t configurable. The upload timeout is:

Parameter Env Default Description
upload_http_timeout_secs GITRIVER_UPLOAD_HTTP_TIMEOUT_SECS 3600 Timeout for a request that uploads a file (package publish, release attachment, site ZIP, CI job artifact and cache archive). Separate from the general 30-second API timeout: a large file doesn’t fit inside that
ai_http_timeout_secs GITRIVER_AI_HTTP_TIMEOUT_SECS 660 Timeout for a request waiting on a model response (analyzing a failed job, analyzing a pull request, checking connectivity to the provider). Separate from the general 30-second API timeout: a reasoning model takes longer to think, and the assistant is allowed to wait up to 600 seconds

The same value also bounds how long an upload holds its reservation in the quota: the reservation doesn’t outlive the request, so a dropped connection or a server restart doesn’t hold the space forever (see space is reserved for the duration of an upload).

The reverse proxy must allow through at least as much. nginx’s client_max_body_size defaults to 1 MiB, and an upload will fail there without ever reaching the server (the example configuration above sets it to 512m).

Beyond the per-request limit, an owner quota also applies — packages, Pages, LFS, CI artifacts, and the CI cache are counted against it together, across all repositories; see the administrator guide.

CI/CD

Parameter Env Default Description
ci_data_path GITRIVER_CI_DATA_PATH {repos}/../ci-data Logs and job working directories
ci_max_concurrent_jobs GITRIVER_CI_MAX_CONCURRENT_JOBS available CPU / ci_docker_cpus, capped at 4 Limit on parallel jobs. The default is computed from available CPU — the core count, bounded by the cgroup quota: with ci_docker_cpus = 2, a four-core server takes two jobs, an eight-core one takes four (see Job Resource Limits)
ci_local_executor_enabled GITRIVER_CI_LOCAL_EXECUTOR true The built-in runner (jobs in Docker on the server). false — the server doesn’t execute jobs and doesn’t need Docker: the whole queue, including runs-on: default and jobs without runs-on, goes to external runners
ci_job_timeout_secs GITRIVER_CI_JOB_TIMEOUT_SECS 3600 Job timeout (seconds)
ci_runner_offline_after_secs GITRIVER_CI_RUNNER_OFFLINE_AFTER_SECS 180 How many seconds of silence before an external runner is considered offline. A runner checks in on every queue poll (--poll-interval, 5 seconds by default); raise this value if your runners poll less often
ci_pipeline_retention_days GITRIVER_CI_PIPELINE_RETENTION_DAYS 90 How long pipelines are kept (0 — forever)
ci_cache_retention_days GITRIVER_CI_CACHE_RETENTION_DAYS 14 How long an unused cache: archive is kept (0 — never evict by age)
ci_docker_memory GITRIVER_CI_DOCKER_MEMORY 2g Job memory limit: docker --memory for a job with image:, the memory.max cgroup for a job without an image
ci_docker_cpus GITRIVER_CI_DOCKER_CPUS 2 CPU limit for the job container. A docker build step doesn’t respect it — see Job Resource Limits
ci_job_pids_limit GITRIVER_CI_JOB_PIDS_LIMIT 512 Limit on the job’s process and thread count: docker --pids-limit for a job with image:, the pids.max cgroup for a job without an image. 0 — unlimited
ci_cgroup_parent GITRIVER_CI_CGROUP_PARENT gitriver.slice The CI resource slice: the cgroup ALL job containers go into. An empty string — no slice. The slice’s own limit is set by systemd, not by this file — see Job Resource Limits
ci_eraser_image GITRIVER_CI_ERASER_IMAGE busybox The image used to clean up a job’s working directory if its container left files owned by root behind (see Cleaning Up Job Directories)
allowed_external_action_domains GITRIVER_ALLOWED_EXTERNAL_ACTION_DOMAINS not set Where external actions may be pulled from (uses: host/owner/repo@ref). Unset — any domain is allowed; [] — all denied. Entries: git.example.com (the whole domain), git.example.com/actions (one owner), git.example.com/actions/checkout (a single repository)
ci_docker_runtime GITRIVER_CI_DOCKER_RUNTIME default Runtime for job containers: default — plain Docker, sysbox — rootless DinD via sysbox-runc, rootless — rootless Docker, privileged — full DinD. The last one lets a job reach the host, and should only be enabled where jobs are trusted as much as a server administrator
ci_host_data_path GITRIVER_CI_HOST_DATA_PATH auto-detected The host-side path for ci_data_path, for Docker-in-Docker. Usually not needed: the path is resolved via docker inspect
ci_git_clone_timeout_secs GITRIVER_CI_GIT_CLONE_TIMEOUT_SECS 120 Timeout for git clone and checkout in a job
ci_lfs_fetch_timeout_secs GITRIVER_CI_LFS_FETCH_TIMEOUT_SECS 600 Timeout for git lfs pull in a job. Separate from cloning: LFS objects can run into hundreds of megabytes
ci_after_script_timeout_secs GITRIVER_CI_AFTER_SCRIPT_TIMEOUT_SECS 300 Timeout for a job’s after_script
ci_runner_poll_secs GITRIVER_CI_RUNNER_POLL_SECS 3 How often the server checks which pipeline jobs are ready to start, in seconds
ci_runner_max_wait_secs GITRIVER_CI_RUNNER_MAX_WAIT_SECS 3600 How long a job waits for a free runner
ci_job_token_ttl_secs GITRIVER_CI_JOB_TOKEN_TTL_SECS 28800 Lifetime of CI_JOB_TOKEN. Must be at least as long as the longest job in the pipeline

Container Registry and Image Scanning

Parameter Env Default Description
ci_registry_host GITRIVER_CI_REGISTRY from base_url Registry address for the CI_REGISTRY variable. Set this to 127.0.0.1 if Docker BuildKit can’t connect to localhost (IPv6 vs. IPv4)
registry_scan_enabled — false Automatic vulnerability scanning of images (Trivy) when a manifest is published. Doesn’t control on-demand scanning: that’s requested by someone with write access to the registry
trivy_path — trivy Path to the Trivy executable
registry_token_expiry_secs GITRIVER_REGISTRY_TOKEN_EXPIRY_SECS 7200 Lifetime of a registry token. Noticeably longer than the others: BuildKit holds the token for the whole build

Registry storage is configured through the [s3] section; without it, images are stored on the filesystem.

Licensing

Parameter Env Default Description
license_server_url GITRIVER_LICENSE_SERVER_URL https://gitriver.ru License server address: the installation’s check-in goes there, and the signed response about renewal or revocation comes from there
update_check_url GITRIVER_UPDATE_CHECK_URL https://gitriver.com/releases/latest Where the installation learns about released versions. An air-gapped environment should point this at its own mirror; an installation behind a proxy, at the proxy’s address
update_check_enabled GITRIVER_UPDATE_CHECK_ENABLED true Whether to check for new versions. For a free installation, this is the only outbound call it makes; false turns it off entirely

An address without https:// is accepted — an air-gapped environment can run its own server there — but the server names this in the log on every start: the check-in carries information about the installation. What exactly is sent: licensing.

Request Timeouts and Token Lifetimes

Parameter Env Default Description
http_request_timeout_secs GITRIVER_HTTP_REQUEST_TIMEOUT_SECS 30 An ordinary /api/v1 request
git_http_timeout_secs GITRIVER_GIT_HTTP_TIMEOUT_SECS 3600 git clone and git push over HTTP: a short timeout would cut off cloning a large repository
lfs_http_timeout_secs GITRIVER_LFS_HTTP_TIMEOUT_SECS 3600 Uploading and downloading LFS objects
registry_http_timeout_secs GITRIVER_REGISTRY_HTTP_TIMEOUT_SECS 3600 Image registry operations
oauth_provider_timeout_secs GITRIVER_OAUTH_PROVIDER_TIMEOUT_SECS 30 Requests to the OAuth2 provider
webhook_timeout_secs GITRIVER_WEBHOOK_TIMEOUT_SECS 10 Request to a webhook receiver
webhook_connect_timeout_secs GITRIVER_WEBHOOK_CONNECT_TIMEOUT_SECS 5 Establishing a connection to a webhook receiver
sse_keepalive_secs GITRIVER_SSE_KEEPALIVE_SECS 15 Interval between keepalive messages on the event stream (SSE)
lfs_token_ttl_secs GITRIVER_LFS_TOKEN_TTL_SECS 900 Lifetime of an LFS token
oauth_token_expiry_secs GITRIVER_OAUTH_TOKEN_EXPIRY_SECS 3600 Lifetime of OAuth and JWT tokens

File upload and assistant request timeouts are covered in File Upload Limits; CI job timeouts are covered in CI/CD.

Job Resource Limits

A built-in runner job runs under memory and process-count limits. The values and what happens when they’re exhausted are described in the CI guide.

How many jobs run at once. ci_max_concurrent_jobs defaults to a value computed from how much CPU is AVAILABLE to the installation: how many jobs fit at the ci_docker_cpus limit per job (available divided by the limit), capped at four. A four-core server with defaults takes two jobs, an eight-core one takes four.

Available isn’t just the core count: the cgroup quota is a ceiling too. A sixteen-core machine with the slice capped at CPUQuota=600% (see below) gives jobs six cores, and the default is computed from six. The quota is read at startup, so restart the service after systemctl set-property.

The server won’t correct a value you set by hand, but it does warn at startup if the CPU it promises jobs exceeds what’s available:

ci_max_concurrent_jobs = 4 при ci_docker_cpus = 2 обещает заданиям 8 ядер,
а доступно 4

(In English: “ci_max_concurrent_jobs = 4 with ci_docker_cpus = 2 promises jobs 8 cores, but only 4 are available”.)

Over-promising costs more than it looks like: a machine overloaded by the built-in runner’s builds stops answering both the API and git — from the outside that looks like the whole installation hanging, not just slow CI.

The per-job limit doesn’t cover all the usage. ci_docker_memory and ci_docker_cpus apply to the job’s CONTAINER. The docker build step inside a job isn’t executed by that container but by the BuildKit daemon in a separate container, and the job’s limits don’t extend to it. While jobs are building images, the per-job limit constrains only the smaller part of the actual usage.

CI Resource Slice

On top of the per-job limits sits a slice — a shared cgroup that everything the runner itself launches falls into:

What How it lands in the slice
The server itself and jobs without image: (run in its own process) Slice=gitriver.slice in the service’s unit file
The job container, after_script, services: --cgroup-parent from the ci_cgroup_parent setting
Action containers (uses:) launched by the job script the same, via the CI_CGROUP_PARENT variable
Housekeeping containers that clean up the working copy the same
An external runner on the same machine Slice=gitriver.slice and --cgroup-parent

The slice’s default name is gitriver.slice, and it must be the same for everything listed above: otherwise the server ends up in one slice and its job containers in another. The first row of the table only applies where the shipped unit file starts the service: for a server started from a terminal or some other way, job containers land in the slice, but the server itself and jobs without image: don’t.

The name is written as *.slice — a value both of Docker’s cgroup drivers accept. An absolute cgroup path is also accepted, but only works with the cgroupfs driver: the systemd driver (the default on current distributions) rejects it. An unusable value is logged by the server at startup, and it runs WITHOUT a slice, while jobs run as usual.

At startup the server checks the slice against the docker daemon’s cgroup driver and logs the result:

срез ресурсов CI: gitriver.slice; отведено ему: ЦП — 6 ядер, память — 12288 МиБ

(In English: “CI resource slice: gitriver.slice; allotted to it: CPU — 6 cores, memory — 12288 MiB”.)

If the cgroup daemon doesn’t manage cgroups at all (Cgroup Driver: none — rootless Docker without delegation, cgroup v1), the slice is dropped with a warning in the log. The line “его cgroup серверу недоступна” (“its cgroup is unreachable by the server”) means something different — containers still land in the slice, but the slice’s limits, its metrics, and lines about it triggering in job logs won’t be readable. For a server running in a container, this is fixed by mounting /sys/fs/cgroup read-only (see “Installing in Docker” below).

The slice’s limit is set by systemd, not by gitriver.toml. There is no such setting in gitriver.toml; the limit is set with:

systemctl set-property gitriver.slice CPUQuota=600% MemoryMax=12G

Leave the machine headroom above the slice — for the OS, the database, and the reverse proxy; the example above is sized for an eight-core, 16 GiB machine. A change made through set-property survives a package upgrade.

Without a limit, the slice still works as an accounting point — and that’s already useful on its own:

systemd-cgtop gitriver.slice

The admin panel shows the same thing without logging into the machine: “System” → “CI Resource Slice” — name, limits, memory in use, and trigger counts, and over time, metrics.

The server gets its own share inside the slice. To keep the API and git responsive on a machine overloaded by builds, the shipped unit file gives the service a higher CPU weight than its neighboring containers in the slice (CPUWeight=10000 against the default of 100), and protects its pages from eviction (MemoryLow=512M). This isn’t a reservation: as long as resources are available, jobs take whatever is free.

Installing in Docker — two lines, and both are required. The gitriver service in docker-compose.prod.yml declares cgroup_parent: gitriver.slice — the same name passed to the server as GITRIVER_CI_CGROUP_PARENT — and mounts the cgroup hierarchy read-only:

    cgroup_parent: ${GITRIVER_CI_CGROUP_PARENT-gitriver.slice}
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:ro

The first line puts the server into the slice. The second lets it SEE the slice. Forgetting the second line isn’t a hard failure, it’s a silent loss of half the picture: job containers still land in the slice, but the slice’s limits, its metrics, and lines about it triggering in job logs disappear. The server states this plainly at startup:

срез ресурсов CI: gitriver.slice; его cgroup серверу недоступна — пределы среза
и метрики по нему не читаются, строк о его срабатываниях в журнале заданий не будет

(In English: “CI resource slice: gitriver.slice; its cgroup is unreachable by the server — slice limits and metrics can’t be read, and no lines about its triggers will appear in job logs”.)

The mount is read-only. It grants no new privileges: docker.sock is already mounted right next to it, and that’s already equivalent to root on the machine.

The slice’s limit is set ON THE HOST, with the same systemctl set-property command: there’s no systemd inside the container.

The install gets upgraded — update the compose file too. The file on your machine is a copy made at install time: docker compose pull && up -d uses that copy, not the one that shipped with the new release. Both lines above need to be carried over into your own file by hand.

Image builds don’t land in the slice, and that isn’t configurable on the server. An image builder the pipeline sets up itself (docker buildx create) runs outside the slice and outside the job’s limits; the --driver-opt cgroup-parent=… option doesn’t move it into the slice. A docker build step without buildx is executed by the docker daemon itself, and neither the slice’s limits nor those of the docker.service service apply to it.

What’s left for the administrator to do:

  • require pipeline authors to limit the builder itself — --driver-opt memory=6g --driver-opt cpu-quota=300000 (see the CI guide);
  • or set a daemon-wide default: the cgroup-parent key in /etc/docker/daemon.json. This applies to ALL containers on the machine, so it fits where the machine is dedicated to GitRiver entirely;
  • or move image builds to an external runner on a separate machine — then its usage is bounded by that machine, not by sharing one with the server.

A job with image: gets its limits from Docker — nothing to configure.

A job without image: runs as a script on the server itself, and cgroup v2 applies its limits. For that, the service needs a delegated cgroup subtree — a line in the unit file:

[Service]
Delegate=cpu pids memory

The package already includes this line — nothing to configure when installing from deb/rpm.

A server running in a Docker container gets a subtree if /sys/fs/cgroup is mounted read-write (--cgroupns=private and a read-write cgroup2 mount).

You can check whether this worked from the server’s log: if the subtree is unavailable, a warning appears on the very first job:

cgroup v2 серверу недоступна: пределы числа процессов и памяти задач БЕЗ `image:`
не накладываются. Задачам с `image:` пределы задаёт Docker. Как включить —
руководство по установке (/usr/share/doc/gitriver/<язык>/installation.md),
раздел «Пределы ресурсов задания»

(In English: “cgroup v2 is unreachable by the server: limits on process count and memory for jobs WITHOUT image: are not applied. Jobs with image: get their limits from Docker. How to enable it — the installation guide (/usr/share/doc/gitriver/<locale>/installation.md), the “Job Resource Limits” section”.)

In this case a job without image: runs with no memory or process limits. The number of concurrent jobs doesn’t drop because of this: the ci_max_concurrent_jobs default is computed from ci_docker_cpus, and that limit is enforced by Docker. An installation where limits are mandatory for every job should require image: on its jobs, or turn off the built-in runner and hand the whole queue to external runners.

Cleaning Up Job Directories

A job script with image: runs in a container as root, and files it creates in the working directory are owned by root, or by an unrelated uid entirely (as happens with docker buildx). With images that set umask 0077 (for example redis:7), the directories end up locked too — the server’s own user can’t delete them.

So the server cleans such directories up with a container: first using the job’s own image, and if that’s not enough (a daemon with userns-remap, files left by buildx) or the job’s image is no longer known (the hourly cleanup of completed jobs), using a service “eraser” image that deletes files owned by root. The external runner cleans up its directories the same way (administrator guide).

The default image is busybox (about 4 MB). If it’s not on the machine, the server pulls it the first time it’s needed; in an air-gapped environment, point this at your own image from a reachable registry — any image with sh, find, and rm will do:

ci_eraser_image = "registry.example.com/base/busybox:1.36"

An unreachable image doesn’t fail silently: the directory stays on disk, and a line with the reason and the path goes to the log.

Lightweight Installation Without Docker (External Runners Only)

By default the server executes CI jobs itself — through the built-in runner, in Docker containers. The ci_local_executor_enabled = false setting turns that off and hands the whole queue to external runners:

GITRIVER_CI_LOCAL_EXECUTOR=false
ci_local_executor_enabled = false

What this gets you. The server no longer needs Docker or access to its socket: GitRiver can be installed on a lightweight machine, or in an environment where policy forbids Docker. It frees up the CPU and memory that would otherwise go to builds — the server is left dealing with just git, the database, and HTTP, and build load spikes move to the runner machines. The server doesn’t touch Docker at all: cleanup of builder containers and build-cache maintenance don’t run, the ci_docker_memory, ci_docker_cpus, and ci_job_pids_limit limits don’t apply, and the local ci_max_concurrent_jobs concurrency limit isn’t used.

What’s still required. Disk space: runners upload job logs and artifacts to the server, into ci_data_path — their volume doesn’t depend on the mode. Size this the same way you would for the built-in runner, and keep ci_pipeline_retention_days (how long pipelines are kept) sensible. The cache between runs (cache:) also works with external runners — the server itself exchanges the archive — so it also occupies ci_data_path: its size is bounded by the owner’s quota (max_ci_cache), and forgotten keys are evicted by ci_cache_retention_days.

Mandatory requirement — a runner labeled default. In this mode, job labels aren’t consulted: the whole queue needs a runner, including runs-on: default and jobs without runs-on. A job without runs-on is picked up by any runner, while runs-on: default is only picked up by one that has the default label declared. Register at least one such runner (admin panel → “Runners”), or jobs will wait until ci_runner_max_wait_secs (an hour by default) and then fail: there’s no “run it on the server” fallback in this mode. While no suitable runner is registered, the admin panel and the setup wizard show a warning. How to install a runner on a build machine: Installing an External Runner.

Installing the runners themselves, and how jobs behave, is covered in the CI guide.

SMTP (Email Notifications)

[smtp]
host = "smtp.example.com"
port = 587
username = "gitriver@example.com"
password = "password"
from = "gitriver@example.com"
starttls = true

Encryption is controlled by one field and has three states:

starttls Port Connection
true (default) any StartTLS — the connection is upgraded to TLS after the greeting
false 465 implicit TLS (SMTPS): encrypted before the greeting
false any other unencrypted — for an internal relay in an air-gapped environment

The server names the last case in the log on every start: the relay account’s password and the message bodies travel over the network in plain text.

Mail can also be configured in the admin panel; its values take priority, and the section is read only when the database doesn’t have settings yet.

LDAP (Corporate Authentication)

[ldap]
url = "ldaps://ldap.example.com:636"
# For url = "ldap://..." the connection is upgraded to TLS via StartTLS.
# Default value — true; false leaves passwords in plain text.
# starttls = true
bind_dn = "cn=service,dc=example,dc=com"
bind_password = "password"
search_base = "ou=users,dc=example,dc=com"
user_filter = "(uid={login})"
email_attr = "mail"
display_name_attr = "displayName"
admin_group_dn = "cn=admins,ou=groups,dc=example,dc=com"
# link_existing_users = false

display_name_attr is the display-name attribute (displayName, cn). Not set — no display name is stored on the record, and the login name is shown everywhere instead.

link_existing_users decides whether signing in through the directory may claim an existing local account with the same name. Off by default: the directory side owns the name in the directory, and a match against the local admin would open that account without going through its password. Accounts created through the directory aren’t affected by this. Turn it on during a migration, when local names deliberately match the ones in the directory.

Directory settings from the admin panel are stored in the database and take priority over this section: the section is read only when the database doesn’t have settings yet.

S3 (Image Registry)

[s3]
endpoint = "https://s3.example.com"
bucket = "gitriver-registry"
access_key = "ACCESS_KEY"
secret_key = "SECRET_KEY"
region = "us-east-1"
# part_size_mb = 5

part_size_mb is the multipart upload part size, in MiB (5 by default, 5 to 100 allowed). Higher — fewer requests to storage for the same volume; lower — a dropped part resends faster after a failure.

Registry storage can also be configured in the admin panel. As soon as it’s set there, the [s3] section isn’t read at all — the server states this in the log at startup. Editing the section on such an installation has no effect.


SSH Access

GitRiver supports SSH for cloning and pushing. Setup:

  1. In the user’s profile: Settings / SSH Keys — add a public key
  2. From there, it depends on the installation method:

Built-in SSH server (no system sshd needed):

ssh_port = 2222

[ssh_server]
listen = "0.0.0.0:2222"
host_key_path = "/var/lib/gitriver/ssh_host_ed25519_key"

In Docker the port is published externally (GITRIVER_SSH_PORT in compose); on Kubernetes, through config.ssh.enabled=true.

System sshd: the server still manages the service account’s authorized_keys itself. The path depends on the installation method — from the package, the gitriver home directory is /var/lib/gitriver:

authorized_keys_path = "/var/lib/gitriver/.ssh/authorized_keys"

Cloning: git clone ssh://git@git.example.com/owner/repo.git

Sessions through the system sshd are handled by the gitriver serv command. Its log goes to the system log (syslog, tagged gitriver-serv), next to sshd’s own login lines: journalctl -t gitriver-serv. The client receives only the reason for a refusal.

Parameter Env Default Description
authorized_keys_path — not set The authorized_keys file the server writes users’ keys into. Not set — authorized_keys management is disabled
ssh_host GITRIVER_SSH_HOST from base_url Public host in the clone address
ssh_port GITRIVER_SSH_PORT the [ssh_server] port, otherwise 22 Public port in the clone address. Not set — the built-in server’s port is taken from listen, or 22 if there’s no built-in server. For a non-standard port, the address takes the form ssh://git@host:port/owner/repo.git
ssh_user GITRIVER_SSH_USER git Username in the clone address
ssh_enabled GITRIVER_SSH_ENABLED automatic Whether to show the SSH tab in the “Clone” dialog. Visible by default if authorized_keys_path or the [ssh_server] section is set; an explicit value overrides both cases — for example, when SSH is served by a separate external daemon. The tab setting in the admin panel takes priority over this value
commit_signing_key_path GITRIVER_COMMIT_SIGNING_KEY_PATH next to the settings file The Ed25519 key the server uses to sign merge commits it makes itself (a merge in the web interface or through the API). Created on first start. Needed so that, with require_signed_commits, the tip of a protected branch stays signed after a merge; without the key, merge commits go out unsigned
listen (in [ssh_server]) GITRIVER_SSH_SERVER_LISTEN required Address and port for the built-in SSH server. Carried into the database on first start with the section present; the admin panel owns the value from then on
host_key_path (in [ssh_server]) GITRIVER_SSH_SERVER_HOST_KEY_PATH next to the settings file The Ed25519 key for the SSH server itself. Created on first start. This variable only has an effect when the built-in server is enabled: on its own, without the [ssh_server] section and without GITRIVER_SSH_SERVER_LISTEN, it enables nothing

The admin panel takes priority over the file. The public ssh_host, ssh_port, ssh_user, and the tab’s visibility can also be set in the SSH section of the admin panel; a value set there overrides the one from the file and applies without a restart. Values from the file apply as long as nothing is set in the panel.

The [ssh_server] section is stricter: it isn’t ongoing management, it’s a one-time seed. On the first start with this section present, its values are carried into the database (logged as ssh bootstrap: TOML [ssh_server] перенесён в БД, i.e. “moved into the database”), and from then on the admin panel owns the built-in server: editing the section in the file no longer changes anything. The same goes for GITRIVER_SSH_SERVER_LISTEN and GITRIVER_SSH_SERVER_HOST_KEY_PATH — they set the same section.


Multiple Replicas

The server is designed to run as multiple instances behind a load balancer (on Kubernetes, several pods of one Deployment). Environment requirements:

  • A shared database — all replicas connect to the same PostgreSQL.
  • Shared storage for repositories, CI data, the image registry, and Pages: a network volume with concurrent write access (ReadWriteMany on Kubernetes), or S3 for the registry. A pod’s local disk won’t work: a request to download an artifact can land on any replica.

Background tasks run on a single replica. The CI scheduler, the merge queue, CI concurrency-group queues, cleanup of artifacts, pipelines, and the registry, GitOps polling, the Kubernetes runner controller, approval requests for deployments to environments, scheduled backups, the license check-in, and the effects of license expiration (revoking Pro seats, writing to the feed) each run on only one replica, and one task running long doesn’t hold up the others. A replica that loses its database connection or is stopped releases its tasks, and another one picks them up on the next pass — no administrator intervention is needed, and there’s no dedicated “primary” pod in the configuration.

Sending queued mail and retrying webhook deliveries run on every replica and share the load. Docker garbage collection (docker volume/image/network prune) runs on every machine with a built-in CI runner — each one has its own daemon.

Rolling out a new version. A stopped pod hands off its background tasks immediately, so a rolling update never leaves them unattended for longer than one of their own intervals.


Backups

There’s one path — the server’s subcommands. They make a full backup (database, repositories, registry, CI data, Pages), verify the archive’s integrity on restore, and return a non-zero exit code if the restore fails. The distribution does not include separate backup scripts; back up and restore only through these subcommands — a wrapper of your own might skip the integrity check.

Settings

Parameter Env Default Description
backup_encryption_key GITRIVER_BACKUP_ENCRYPTION_KEY not set Backup encryption key (base64, 32 bytes). Set — the archive is encrypted with AES-256-GCM
backup_token_ttl_secs GITRIVER_BACKUP_TOKEN_TTL_SECS 60 Lifetime of a one-time backup download link

Keep the encryption key separate from the backups: together, they protect against nothing, and apart, losing the key means losing the backups. Changing the key doesn’t re-encrypt archives already made — they can still be restored with the old key, but not with the new one.

Creating a Backup

gitriver backup --config gitriver.toml --output backup.tar.gz

Includes: the database, repositories, the registry, CI data, Pages.

Flags:

  • --repos false — exclude repositories
  • --registry — include the image registry
  • --ci — include CI data
  • --pages — include Pages

Restoring

gitriver restore --config gitriver.toml backup.tar.gz

Automatic Schedule

Configured in the web interface: Administration / Backups / Schedule.


Upgrading

# image
cd /opt/gitriver
docker compose pull gitriver
docker compose up -d gitriver

# package
sudo apt install ./gitriver_NEW-VERSION_amd64.deb    # or dnf upgrade

# Helm
helm upgrade gitriver gitriver-NEW-VERSION.tgz --reuse-values --set image.tag=NEW-VERSION

The server upgrades the database schema itself at startup — none of the three methods has a separate upgrade step. If the server stops with a schema upgrade message, see “Troubleshooting” below.

The GITRIVER_RATE_LIMIT_DISABLED variable has no effect. Rate limits are lifted through the rate_limit_exempt_cidrs setting (GITRIVER_RATE_LIMIT_EXEMPT_CIDRS), which lists the exempt addresses. If you have the variable set, rate limits apply to everyone after the upgrade — the server logs this at startup; move the addresses you need into rate_limit_exempt_cidrs.


Troubleshooting

The Setup Wizard Doesn’t Appear

Make sure database_url is not set, in either the TOML file or the environment. The wizard only starts when there’s no database connection.

Token Errors After Restart

The token signing key is stored in /var/lib/gitriver/.jwt_secret (from the package — /etc/gitriver/.jwt_secret). If needed, set jwt_secret explicitly in the config. On Kubernetes there’s no file: the key arrives as the GITRIVER_JWT_SECRET environment variable, sourced from a Secret — see Token Signing Key. The first suspect there is a GitOps sync fighting a key the chart generates on its own.

Losing the key doesn’t just cost sessions. Everything encrypted in the database on the basis of it becomes unreadable along with it:

What’s encrypted What happens
Users’ TOTP secrets two-factor sign-in stops working for everyone who enabled it; an administrator has to reset it one user at a time
OAuth application client_secret values (GitRiver as a provider) connected applications stop receiving tokens; secrets need to be reissued
Access tokens for external registries in the proxy cache serving private sources through the proxy stops until tokens are re-entered
The language model provider’s access key the assistant stops responding until the key is re-entered
The id_token signing key (GitRiver as an OIDC provider) the installation creates a new one and publishes it in the JWKS; previously issued id_token values stop validating, and recipients experience this as a one-time re-login

That’s why .jwt_secret is part of the backup, not a disposable file, and shouldn’t be rotated “just in case.” Rotating it is justified when the key has leaked; in that case, everything listed above needs to be re-entered deliberately, not discovered later through user complaints.

CI Jobs Don’t Start

  1. The Docker socket is reachable: -v /var/run/docker.sock:/var/run/docker.sock
  2. The gitriver user is in the docker group (the image does this on its own at startup)
  3. Check ci_max_concurrent_jobs — the limit may have been reached
  4. If ci_local_executor_enabled = false, jobs are waiting for an external runner. Open the admin panel → “Runners”: it shows whether a runner is online and whether it has the default label declared (without it, nothing can pick up jobs with runs-on: default). See Lightweight Installation Without Docker

Git Push Is Rejected

  • Check branch protection (repository settings → “Branch protection”)
  • For LFS: set client_max_body_size in nginx

Server Won’t Start: “Found NuGet packages differing only in case”

NuGet identifiers are case-insensitive: Newtonsoft.Json and newtonsoft.json are the same package. If a single repository holds two records of such a package, the server doesn’t merge them on its own — files and metadata can differ between them for the same version — and stops the upgrade until you reconcile them by hand.

Find the conflicting records:

SELECT r.name AS repo, LOWER(p.name) AS package_id,
       ARRAY_AGG(p.name ORDER BY p.created_at) AS variants
FROM packages p
JOIN repositories r ON r.id = p.repo_id
WHERE p.type = 'nuget'
GROUP BY r.name, p.repo_id, LOWER(p.name)
HAVING COUNT(*) > 1;

For each group, decide which spelling to keep (usually the one published first), and move the versions from the other records over to it:

BEGIN;
UPDATE package_versions SET package_id = '<id of the package to keep>'
 WHERE package_id = '<id of the extra package>';
DELETE FROM packages WHERE id = '<id of the extra package>';
COMMIT;

If the same version was published under both records, the UPDATE fails with package_versions_package_id_version_key: that’s two different publications of the same version number. Decide which one is correct, delete the other (DELETE FROM package_versions WHERE id = ...), and repeat the move.

Restart the server after merging — the upgrade will then continue.

Server Won’t Start: “contains several equivalent NuGet versions”

NuGet version numbers are normalized (1.0, 1.0.0, and 1.0.0.0 are the same version), and the server won’t collapse two publications of the same version into one: they have different files. Related failures from the same upgrade are “does not match the NuGet format” (a version there’s nothing to normalize) and “contains several .nupkg files” (it’s unclear which file is the version’s file).

Find the equivalent versions:

SELECT p.name AS package, pv.package_id,
       ARRAY_AGG(pv.version ORDER BY pv.created_at) AS variants
FROM package_versions pv
JOIN packages p ON p.id = pv.package_id
WHERE p.type = 'nuget'
GROUP BY p.name, pv.package_id,
         regexp_replace(pv.version, '(\.0)+$', '')
HAVING COUNT(*) > 1;

Decide which publication is correct, delete the other (DELETE FROM package_versions WHERE id = ...), and restart the server. The query above is approximate; the exact record is named by the failure message itself, one per run.

Server Won’t Start: “name is claimed by both a user and a group”

A username and a group path must differ: a name held by both would make renaming one also rename the other. Find such names:

SELECT u.username FROM users u JOIN groups g ON g.path = u.username;

Rename one side of the conflict — the user in the admin panel, or the group on its own page — and restart the server.

Server Won’t Start: “license activations reference different instances”

A sign that the database was copied, and both copies activated the license independently. This can’t be fixed on your own, and the database shouldn’t be edited by hand: write to the publisher (info@gitriver.ru), attach the full text of the failure, and say which copy is the working installation. The publisher reissues the license; the response is entered manually on the license page.

If both copies are meant to run as separate installations, each one needs its own license. What to do after moving the database or restoring it from a copy is described in Moving the Database and Restoring From a Copy.