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
pgcryptoextension available (the shippeddocker-compose.prod.ymlbrings up 18; on the RHEL family the extension is in thepostgresql-contribpackage) - 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 isjwt-secretby default). The only option suitable for GitOps;config.jwtSecret— the key as a plain value (ends up in the<release>-gitriver-signing-keySecret as the plain-text value fromvalues);- neither set — the chart generates a key on first install, stores it in the
<release>-gitriver-signing-keySecret, and carries the existing value forward onhelm upgrade. The Secret is markedhelm.sh/resource-policy: keep, like the data volumes: it surviveshelm 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_sizedefaults to 1 MiB, and an upload will fail there without ever reaching the server (the example configuration above sets it to512m).
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-parentkey 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:
- In the user’s profile: Settings / SSH Keys — add a public key
- 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 asssh 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 forGITRIVER_SSH_SERVER_LISTENandGITRIVER_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 (
ReadWriteManyon 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
- The Docker socket is reachable:
-v /var/run/docker.sock:/var/run/docker.sock - The
gitriveruser is in thedockergroup (the image does this on its own at startup) - Check
ci_max_concurrent_jobs— the limit may have been reached - 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 thedefaultlabel declared (without it, nothing can pick up jobs withruns-on: default). See Lightweight Installation Without Docker
Git Push Is Rejected
- Check branch protection (repository settings → “Branch protection”)
- For LFS: set
client_max_body_sizein 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.