Technical Administration
Operation, configuration, backup, updates and troubleshooting
Audience: Technical administration
Overview
This part is for everyone who runs the oPBWeb Docker stack: host, containers, configuration files, secrets, backup and updates. You do not need a role in oPBWeb for this work, but you do need access to the Docker host and the project directory.
Run all commands in the project directory on the Docker host. The settings an administrator makes in the user interface (config servers, depot stages, users, object storage cleanup) are described in Functional Administration
. The initial setup is covered in Installation
.
Chapters
- Architecture
– services, networks, data flow, object storage layout, worker and jobs, how the manual is served.
- Configuration
– variables in
.env, secrets, TLS and COOKIE_SECURE, ports, time limits, image overrides and settings without an .env variable. - LDAP Sign-in
– sign-in via LDAP or Active Directory, mapping groups to roles, CA certificate, troubleshooting.
- Operation and Updates
–
start.sh and stop.sh, containers, logs, health endpoints, updates, versioning, development stack, scheduled tasks. - Backup and Restore
– what to back up, commands, order and consistency when restoring.
- Troubleshooting
– typical symptoms with cause and fix, table of important error messages.
- Build and Delivery
– images, GitLab CI pipeline, building the manual locally and as PDF.
Architecture
Services, networks, data flow, object storage and background jobs of the oPBWeb stack
Audience: Technical administration
Overview
oPBWeb runs as a single Docker Compose project named opbweb. The file docker-compose.yml describes production operation; docker-compose.dev.yml extends it for development. This chapter shows which services exist, how they are connected and where each kind of data is stored.
Services
| Service | Container | Image | Purpose |
|---|
| frontend | opbweb-frontend | opbweb-frontend:latest (nginx:stable-alpine) | serves the web interface and the manual, forwards /api to the API; the only service with published ports |
| api | opbweb-api | opbweb-backend:latest (python:3.13-slim) | REST API and WebSocket (FastAPI/uvicorn on port 8000), sign-in, database migrations at startup, first administrator account |
| worker | opbweb-worker | opbweb-backend:latest | runs all jobs (arq on Redis); the only service that talks to the opsi config servers and decrypts their passwords |
| db | opbweb-db | mariadb:11.4 | all application data (packages, versions, users, jobs, audit log, settings) |
| redis | opbweb-redis | redis:7-alpine | job queue, worker heartbeat, opsi connection status, revocation list of signed-out tokens, depot locks |
| garage | opbweb-garage | dxflrs/garage:v2.2.0 | S3-compatible object storage (single node, replication factor 1) for file contents and built packages |
| garage-init | opbweb-garage-init | opbweb-garage-init:1.0.0 (alpine:3.21) | sets up the layout, the access key opbweb and the bucket packages on every start, then exits |
API and worker use the same image; the worker just starts with a different command (./start-worker.sh). The garage-init image carries the :dev tag in production as well.
All services except garage-init run with restart: unless-stopped and start automatically after a host reboot.
Startup order
- db, redis and garage start and report readiness through their health checks.
- garage-init waits for the Garage admin API, creates missing resources and writes the S3 credentials to the volume
garage-credentials. - api starts once db and redis are healthy and garage-init has exited successfully. It waits up to 60 seconds for the database, runs
alembic upgrade head and creates the first administrator account as long as the user table is empty. - worker starts after the API, writes a first heartbeat and checks the connection to all config servers.
- frontend starts after the API and chooses HTTPS or HTTP depending on the certificates.
Networks
| Network | Property | Members |
|---|
frontend-net | normal | frontend, api |
backend-net | internal: true (no outside access) | api, worker, db, redis, garage |
opsi-net | normal, outbound | worker |
Only the two frontend ports (FRONTEND_HTTP_PORT, FRONTEND_HTTPS_PORT) are reachable from outside. Database, Redis and Garage have no published ports in production. The API has no route to the outside; only the worker reaches the opsi config servers and their depots via opsi-net (HTTPS, port 4447). The actual Docker networks carry the project prefix, for example opbweb_opsi-net.
Data flow
Browser
│ HTTPS 443 (or HTTP 80)
▼
┌──────────────────────── frontend (nginx) ────────────────────────┐
│ / web interface (Vue SPA) │
│ /manual/ manual in German, /manual/en/ in English │
│ /health static response "healthy" │
│ /api proxy incl. WebSocket ──────────────┐ │
└──────────────────────────────────────────────────┼───────────────┘
frontend-net ▼
┌──── api :8000 ────┐
│ sign-in, REST, │
│ job events (WS) │
└───┬──────┬─────┬──┘
backend-net (internal) │ │ │ enqueue job
┌─────────────────────────┘ │ ▼
▼ ▼ redis ◄──────┐
db (MariaDB) garage (S3, bucket packages) │
▲ ▲ │
└──────────────┬─────────────────┘ │
│ │
┌── worker ──┐ ─────────────────────────┘
│ arq jobs, │ fetches jobs, heartbeat
│ cron │
└─────┬──────┘
│ opsi-net, HTTPS :4447
▼
opsi configserver and depots (opsiconfd)
JSON-RPC and WebDAV /repository, /workbench, /depot
Typical flows:
- Editing in the user interface: browser → nginx → API → database and object storage. The worker is not involved.
- Long-running operations (import, build, install, depot sync, workbench, file manager copies): the API creates a job in the database and enqueues it in Redis. The worker runs it and writes progress and log. The browser follows the job live via WebSocket at
/api/jobs/{id}/events. - File manager on the depots: the API passes single file operations (list, read, save) directly to the worker and waits up to 60 seconds for the answer. If the worker does not respond, “Worker not reachable.” or “The worker does not respond.” appears.
- Uploads: nginx accepts uploads up to 20 GB and passes them to the API unbuffered. The API stores them in the object storage under
uploads/; a job processes them. - Downloads: the API serves built packages from the object storage through short-lived download links (valid for 2 minutes). Files from a depot are first copied to
downloads/ by a job.
Data storage
Database
MariaDB (database opbweb, character set utf8mb4) holds all metadata: users, audit log, products and versions with control data, properties, dependencies, the file list of each version with SHA-256 checksum, build results, jobs with logs, snippets, application settings, the config servers with their connection data and stages, and the depots, each of which is assigned to exactly one config server. The password of the technical opsi user is stored there encrypted for each config server (RSA-OAEP): the API encrypts it with secrets/opsi_key_public.pem, only the worker can decrypt it with secrets/opsi_key_private.pem.
Object storage
Garage stores all file contents in the bucket packages. Objects are organized by prefix:
| Prefix | Content | Lifetime |
|---|
blobs/<2 characters>/<sha256> | contents of package files, addressed by SHA-256 | permanent; identical files of several versions or packages are stored only once (deduplication) |
artifacts/<productId>/<file name> | built packages: .opsi, .md5, .zsync | permanent; a new build replaces the previous result of the version |
uploads/<id>/<file name> | uploaded files until they are processed | temporary, deleted after 24 hours |
downloads/<job ID>/<file name> | files fetched from a depot for download | temporary, deleted after 24 hours |
Because of deduplication, deleting packages or versions leaves blobs behind that no package file uses any more. An administrator removes them deliberately with Clean up … on the System status
page.
Physically, the data lives in two Docker volumes: garage-meta (metadata in LMDB, keys, bucket) and garage-data (data blocks). The prefixes above are object keys in the bucket, not directories in the volume. The layout capacity of 100 GB set by garage-init is a weighting for Garage, not a quota; what counts is the free space on the host.
Volumes
| Volume | Service | Content |
|---|
mariadb-data | db | database files |
redis-data | redis | queue and status (AOF persistence) |
garage-meta | garage | object storage metadata |
garage-data | garage | object storage data blocks |
garage-credentials | garage-init, api, worker | S3 credentials, written by garage-init |
Docker creates the volumes with the prefix opbweb_, for example opbweb_mariadb-data. Which of them to back up is described in Backup and Restore
.
Worker and jobs
The worker processes the jobs from Redis:
- up to 10 jobs at the same time, so that short file manager calls do not wait behind long installations,
- time limit for jobs: 8 hours (
JOB_TIMEOUT_SECONDS), 3600 seconds for the other worker functions, - jobs for the same depot run one after another. A depot lock in Redis ensures this; a job waits up to 6 hours for it and otherwise aborts with “Depot has been busy with another job for too long.”
The worker also runs scheduled tasks:
| Task | Schedule | Effect |
|---|
heartbeat | every 30 seconds | writes a sign of life to Redis; if it is older than 90 seconds, the system status shows the worker as failed |
check_opsi | every 5 minutes (configurable) | checks every configured config server (sign-in, admin rights, opsi version, depots), syncs the depots of every reachable server, compares the CA certificate and warns 60 days before it expires |
cleanup_storage | daily at 03:17 (configurable) | deletes objects under uploads/ and downloads/ that are older than 24 hours |
The time of day follows TZ (default Europe/Berlin). The automatic cleanup does not delete blobs or build results.
Manual
This manual is part of the frontend image. During the build, a dedicated Hugo stage generates the static website, and nginx serves it:
- German at
/manual/, - English at
/manual/en/.
The book icon in the top bar opens the manual in the language of the user interface in a new tab. The manual is static and reachable without signing in. Changes to the manual appear only with a newly built frontend image, see Build and Delivery
.
Configuration
Variables in .env, secrets, TLS, ports, time limits and image overrides
Audience: Technical administration
Overview
oPBWeb splits its configuration into three levels:
.env in the project directory: non-secret settings such as ports, LDAP server, time limits. Docker Compose reads the file and inserts the values into docker-compose.yml.secrets/: all passwords and keys as individual files. They reach the containers as Docker secrets and are never stored in .env.- User interface: the connections to the opsi config servers (URL, technical user, password, stage, CA certificate; in the Depots view) and the defaults for new packages. These settings are stored in the database, see Config servers
and Settings for new packages
.
./setup-opbweb.sh creates .env and the missing secrets, see Installation
. Afterwards you can edit .env directly or run the script again; the existing values are then the defaults. Changes to .env take effect with the next ./start.sh; Docker Compose then recreates the affected containers.
Variables in .env
The template .env.example contains all variables of the following tables; commented-out lines show the default. If a variable is missing from .env, its default applies. setup-opbweb.sh writes the variables for database, Redis, object storage and CORS_ORIGINS only if they were already in the previous .env; variables the script does not know are kept at the end of the file.
General and sign-in
| Variable | Default | Service | Meaning |
|---|
TZ | Europe/Berlin | all | time zone of the containers, also for the time of the daily cleanup (see Settings → Worker
) |
LOG_LEVEL | INFO | api, worker | log verbosity: DEBUG, INFO, WARNING or ERROR. From INFO on, the API logs the uvicorn start, every HTTP request and problems with LDAP sign-in, see Logs |
DEBUG | false | api, worker | true starts uvicorn with --reload (hot reload of the API) and the worker with file watching, and also enables the debug output of httpx and python-opsi; for development only. Outside --dev, setup-opbweb.sh warns if the value is true |
OPBWEB_LANG | system locale | scripts | Language of setup-opbweb.sh, start.sh and scripts/init-secrets.sh: de or en. Set by setup-opbweb.sh (option --lang); if missing, the locale decides (de… → German, otherwise English). Does not affect the oPBWeb user interface |
ADMIN_USERNAME | admin | api | name of the first administrator account; only applies while the user table is empty |
COOKIE_SECURE | false | api, worker | setup-opbweb.sh sets true, because oPBWeb always runs via HTTPS (see TLS and COOKIE_SECURE
); false only in the development stack |
CORS_ORIGINS | ["http://localhost:3100"] | api | allowed origins as a JSON list; only needed for the Vite dev server, in production everything comes from the same origin |
ACCESS_TOKEN_MINUTES | 15 | api | validity of the access token in minutes (renewed automatically in the browser) |
REFRESH_TOKEN_DAYS | 7 | api | validity of the refresh cookie in days, i.e. how long a sign-in lasts without entering the password again |
ACCOUNT_MAX_FAILED_ATTEMPTS | 5 | api | failed attempts after which a local account is locked for the address they came from. One address may have four times as many failed attempts in total (all accounts, LDAP included); after that the API answers with 429. An account is locked for all addresses only after ten times as many failed attempts. |
ACCOUNT_LOCKOUT_MINUTES | 15 | api | duration of the locks and period in which failed attempts count |
LDAP_* | – | api | sign-in via LDAP/AD, see LDAP Sign-in |
opsi and packages
| Variable | Default | Service | Meaning |
|---|
OPSI_TIMEOUT_SECONDS | 60 | worker | time limit in seconds for all other opsi calls (JSON-RPC, WebDAV listing) |
OPSI_INSTALL_TIMEOUT_SECONDS | 3600 | worker | time limit in seconds for the upload to the repository and for depot_installPackage or depot_uninstallPackage |
JOB_TIMEOUT_SECONDS | 28800 | worker | upper limit in seconds for a whole job, e.g. an installation on several depots including waiting for busy depots |
BUILD_COMPRESSION | zstd | worker | compression of built .opsi files: zstd (opsi 4.3 default) or gzip |
BUILD_COMPRESSION only affects new builds. Packages that are already built keep their compression until they are built again.
Frontend and images
| Variable | Default | Meaning |
|---|
FRONTEND_HTTP_PORT | 80 | host port for HTTP |
FRONTEND_HTTPS_PORT | 443 | host port for HTTPS |
BACKEND_IMAGE | opbweb-backend:latest (dev: :dev) | image for api and worker |
FRONTEND_IMAGE | opbweb-frontend:latest | image for frontend |
GARAGE_INIT_IMAGE | opbweb-garage-init:1.0.0 | image for garage-init |
OPBWEB_REGISTRY | registry of the GitLab CI (registry.gitlab.open-mind.space/pages/opsipackagebuilder-web) | only for setup-opbweb.sh: prefix from which the script builds the image names (<prefix>/backend:<version> etc.); can also be set as an environment variable when calling the script |
If the three image variables are empty, the locally built images apply, see Image overrides and registry mirror
.
Database, Redis, object storage
You normally do not need to set these variables. They exist for special cases, such as external services instead of the bundled containers; .env.example lists them commented out.
| Variable | Default | Meaning |
|---|
DB_NAME | opbweb | name of the database that MariaDB creates on first start |
DB_USER | opbweb | database user |
DATABASE_URL | mysql+asyncmy://opbweb@db:3306/opbweb | application connection (without password; the application inserts the password from the secret) |
DATABASE_URL_SYNC | mysql+pymysql://opbweb@db:3306/opbweb | connection for the migrations |
REDIS_URL | redis://redis:6379/0 | Redis (without password) |
S3_ENDPOINT | garage:3900 | Garage S3 endpoint |
S3_BUCKET | packages | bucket; garage-init creates it under this name |
S3_SECURE | false | TLS to the S3 endpoint |
Warning
Do not change DB_NAME and DB_USER after the first start. MariaDB creates the database and the user only when the volume is initialized for the first time. In addition, the default URLs contain the database name opbweb hard-coded; if you change DB_NAME, you must set DATABASE_URL and DATABASE_URL_SYNC accordingly.
Secrets
scripts/init-secrets.sh creates missing secrets in the directory secrets/. setup-opbweb.sh and start.sh call the script, start.sh on every start. It never overwrites existing files. Do not share the contents of secrets in tickets or logs.
The secrets/ directory has permissions 750, the files 444; the script sets them again on every run. Docker passes the secret files into the containers with the host permissions, and the processes there run as other users (MariaDB, Redis, the application as appuser with UID 1000). With 600 they could not read the files on a Linux host. The directory with 750 keeps other users of the host out.
| File | Generated as | Used by | Purpose |
|---|
db_password.txt | random value (hex) | db, api, worker | password of the MariaDB user opbweb |
db_root_password.txt | random value (hex) | db | MariaDB root password, also used for dumps |
redis_password.txt | random value (hex) | redis, api, worker | Redis password |
redis.acl | from redis_password.txt | redis | Redis ACL file |
jwt_secret_key.txt | random value (hex, 64 characters) | api | signature of the sign-in tokens |
opsi_key_public.pem | RSA key (3072 bit), public part | api, worker | encrypts the stored opsi password |
opsi_key_private.pem | RSA key (3072 bit), private part | worker | decrypts the opsi password; only the worker talks to opsi |
settings_encryption_key.txt | random value (hex, 64 characters) | worker | AES-256 key of versions before 0.18.6; the worker reads old entries with it and re-encrypts them at start |
garage_rpc_secret.txt | random value (hex, 64 characters) | garage | Garage RPC secret |
garage_admin_token.txt | random value (hex, 64 characters) | garage, garage-init | token for the Garage admin API |
admin_password.txt | 20 characters | api | password of the first administrator; shown once when the account is created |
ldap_bind_password.txt | empty; setup-opbweb.sh writes the password you enter into it | api | password of the LDAP service account |
The S3 credentials are not in secrets/. garage-init generates the key in Garage and writes it to the volume garage-credentials.
Changing secrets later
Not every secret can simply be replaced after the first start:
| File | Consequence of a change |
|---|
db_password.txt, db_root_password.txt | MariaDB adopts the passwords only on first initialization. After that, a changed file no longer matches the database; you would also have to change the password in MariaDB itself. |
redis_password.txt | start.sh creates redis.acl from the new password on the next start and restarts Redis. |
jwt_secret_key.txt | All sessions become invalid; everyone has to sign in again. |
opsi_key_private.pem, opsi_key_public.pem | Replace only as a pair (delete both, init-secrets.sh creates a new pair). The stored opsi passwords can no longer be decrypted afterwards; enter them again under Depots → config server for each config server. |
settings_encryption_key.txt | Only needed for entries from versions before 0.18.6; do not change it while such entries still need converting. |
garage_rpc_secret.txt, garage_admin_token.txt | Do not change; they belong to the existing object storage. |
admin_password.txt | Only applies while no user exists. Later, the administrator changes their password in the user interface. |
ldap_bind_password.txt | Not critical; restart the stack after the change. |
The files are read-only (444). To change one, briefly remove the protection and set it again afterwards, for example:
chmod u+w secrets/ldap_bind_password.txt
# edit the file
chmod 444 secrets/ldap_bind_password.txt
You can also enter the LDAP password again with ./setup-opbweb.sh. The services read the secrets at startup. After a change, restart the stack (./stop.sh, then ./start.sh).
TLS and COOKIE_SECURE
On every start, the frontend container decides based on the files in the directory certs/:
| Present | nginx serves |
|---|
certs/fullchain.pem and certs/key.pem | HTTPS on container port 443 (host: FRONTEND_HTTPS_PORT), HTTP/2, TLS 1.2 and 1.3; container port 80 (host: FRONTEND_HTTP_PORT) redirects with 301 to https://<host>:<FRONTEND_HTTPS_PORT> |
| one or both files missing | HTTP only on container port 80 (only in the development stack or with a manually created .env) |
Outside the development stack, setup-opbweb.sh always provides a certificate: supplied (fullchain.pem and key.pem next to the script), existing or self-signed, see Installation
. The container log shows the choice: „TLS-Zertifikat gefunden – HTTPS auf Port 443“ (TLS certificate found – HTTPS on port 443) or „Kein TLS-Zertifikat – HTTP auf Port 80“ (no TLS certificate – HTTP on port 80). fullchain.pem contains the server certificate with all intermediate certificates. The container gets the target port of the redirect as HTTPS_PORT (= FRONTEND_HTTPS_PORT).
With HTTPS, nginx sends the header Strict-Transport-Security (one year). Browsers that have opened oPBWeb via HTTPS once will refuse HTTP for this host name afterwards.
After replacing a certificate (put the new files next to the script and run ./setup-opbweb.sh --updateonly, or replace them directly in certs/), restarting the frontend is enough:
docker restart opbweb-frontend
In addition, nginx lets at most 10 sign-in attempts per minute and address through (briefly 10 more). Both limits require oPBWeb to see the real client address; if another proxy sits in front or Docker hides the addresses, all users share one address.
COOKIE_SECURE controls the Secure flag of the refresh cookie opbweb_refresh (valid for 7 days, httpOnly, SameSite=Strict). If the sign-in arrives over HTTPS, the API always sets the flag, even with false. For the setting:
| Operation | COOKIE_SECURE |
|---|
| HTTPS | true |
| HTTP only | false; with true, the browser does not store the cookie and the session cannot be renewed |
Ports
| Container port | Host port | Service |
|---|
| 80 | FRONTEND_HTTP_PORT (80) | nginx, HTTP |
| 443 | FRONTEND_HTTPS_PORT (443) | nginx, HTTPS |
Both ports are always published, but only one of them responds. All other services are not reachable from outside in production. The additional ports of the development stack are listed under Operation and Updates
.
Outbound, the worker needs HTTPS on port 4447 to the opsi configserver and the depots.
Time limits and sizes
| Limit | Value | Configurable |
|---|
| Upload and install/uninstall on the depot | 3600 s | OPSI_INSTALL_TIMEOUT_SECONDS |
| Other opsi calls | 60 s | OPSI_TIMEOUT_SECONDS |
| Job runtime | 8 h (28800 s) | JOB_TIMEOUT_SECONDS |
| Parallel jobs in the worker | 10 | no |
| File manager calls API → worker | 60 s | no |
| Waiting for an occupied depot | up to 6 h | no |
| Upload size through nginx | 20 GB | no (client_max_body_size in frontend/nginx/common.inc) |
| nginx timeout to the API | 660 s without data transfer | no |
| Download links | 2 min | no |
| Text files in the editor | 5 MB (package files), 2 MB (file manager) | no |
Note
OPSI_INSTALL_TIMEOUT_SECONDS limits a single opsi call, JOB_TIMEOUT_SECONDS the whole job – for an installation that is all depots one after the other, including waiting for busy depots (up to 6 h). Therefore set JOB_TIMEOUT_SECONDS well above that waiting time. If a job exceeds the limit or the worker is stopped, the job ends with “Cancelled: job time limit exceeded or worker stopped.” and counts as failed.
Image overrides and registry mirror
Without overrides, ./start.sh --build builds your own images locally and names them opbweb-backend:latest, opbweb-frontend:latest and opbweb-garage-init:1.0.0. With BACKEND_IMAGE, FRONTEND_IMAGE and GARAGE_INIT_IMAGE you use prebuilt images instead, for example from the GitLab registry (see Build and Delivery
). setup-opbweb.sh sets the three variables when you choose r at the images question; the prefix comes from OPBWEB_REGISTRY. Done manually, it looks like this (example, adapt it to your registry):
BACKEND_IMAGE=registry.example.com/gruppe/opsipackagebuilder-web/backend:<version>
FRONTEND_IMAGE=registry.example.com/gruppe/opsipackagebuilder-web/frontend:<version>
GARAGE_INIT_IMAGE=registry.example.com/gruppe/opsipackagebuilder-web/garage-init:latest
Then pull the images and start without --build:
docker login registry.example.com
docker compose pull
./start.sh
LDAP Sign-in
Set up sign-in via LDAP or Active Directory and map groups to roles
Audience: Technical administration
Overview
In addition to local accounts, oPBWeb can sign users in against an LDAP directory or Active Directory. The role in oPBWeb is then derived from the group memberships in the directory. You configure the connection with LDAP_* variables in .env and the password of the service account in secrets/ldap_bind_password.txt. There are no settings for this in the user interface.
How sign-in works
- If oPBWeb has a local account with the entered user name, oPBWeb signs in locally only. The directory is not queried, even if the password is wrong.
- Otherwise, with
LDAP_ENABLED=true, oPBWeb connects with the service account (LDAP_BIND_DN), or anonymously if no bind DN is set. - oPBWeb searches with
LDAP_USER_FILTER below LDAP_USER_SEARCH_BASE (empty: LDAP_BASE_DN). The search must return exactly one entry. - oPBWeb binds as the user with the DN it found and the entered password, which verifies the password.
- oPBWeb determines the role from the
memberOf attribute (see below). Without a matching group, there is no access. - On the first successful sign-in, oPBWeb creates the account with the source Directory (LDAP). Display name (
displayName), email (mail) and role are taken from the directory again on every sign-in.
Local accounts therefore always stay local, including the first administrator account. This way you can still get into oPBWeb if the directory is down.
Variables
| Variable | Default | Meaning |
|---|
LDAP_ENABLED | false | enable LDAP sign-in |
LDAP_HOST | – | host name of the domain controller or LDAP server |
LDAP_PORT | 636 | port |
LDAP_USE_SSL | true | LDAPS (TLS from connection setup); StartTLS is not supported |
LDAP_TLS_VERIFY | true | verify the server certificate |
LDAP_CA_CERTS_FILE | – | CA file inside the container, e.g. /run/certs/ldap/ca.pem |
LDAP_BASE_DN | – | base DN of the directory |
LDAP_USER_SEARCH_BASE | – | search base for users; empty = LDAP_BASE_DN |
LDAP_USER_FILTER | (&(objectClass=user)(sAMAccountName={username})) | search filter; {username} is replaced by the entered name (escaped) |
LDAP_BIND_DN | – | DN of the service account used for the search; empty = anonymous search |
LDAP_ADMIN_GROUPS | – | group DNs for the Administrator role |
LDAP_RELEASER_GROUPS | – | group DNs for the Releaser role |
LDAP_PACKAGER_GROUPS | – | group DNs for the Packager role |
LDAP_VIEWER_GROUPS | – | group DNs for the Viewer role |
The service account password is not in .env but in secrets/ldap_bind_password.txt (created empty by init-secrets.sh). The connection has a time limit of 10 seconds for setup and 15 seconds for responses.
Warning
With LDAP_USE_SSL=false, user passwords travel over the network unencrypted. Use LDAPS in production. LDAP_TLS_VERIFY=false disables certificate verification and is meant only for a short test.
Active Directory example
An example for the domain example.local. Replace all names with your values:
LDAP_ENABLED=true
LDAP_HOST=dc01.example.local
LDAP_PORT=636
LDAP_USE_SSL=true
LDAP_TLS_VERIFY=true
LDAP_CA_CERTS_FILE=/run/certs/ldap/ca.pem
LDAP_BASE_DN=dc=example,dc=local
LDAP_USER_SEARCH_BASE=ou=Benutzer,dc=example,dc=local
LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName={username}))
LDAP_BIND_DN=cn=svc-opbweb,ou=Dienstkonten,dc=example,dc=local
LDAP_ADMIN_GROUPS=cn=opbweb-admins,ou=Gruppen,dc=example,dc=local
LDAP_RELEASER_GROUPS=cn=opbweb-releaser,ou=Gruppen,dc=example,dc=local
LDAP_PACKAGER_GROUPS=cn=opbweb-packager,ou=Gruppen,dc=example,dc=local;cn=client-team,ou=Gruppen,dc=example,dc=local
LDAP_VIEWER_GROUPS=cn=opbweb-leser,ou=Gruppen,dc=example,dc=local
More filters, adapt them to your directory:
| Purpose | Filter |
|---|
| sign-in with UPN instead of account name | (&(objectClass=user)(userPrincipalName={username})) |
| exclude disabled AD accounts | (&(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2))) |
OpenLDAP with uid | (&(objectClass=inetOrgPerson)(uid={username})) – requires the memberOf overlay |
Setup
Enter the variables in .env as shown above.
Write the service account password to secrets/ldap_bind_password.txt (without a trailing line break is safest; oPBWeb strips leading and trailing whitespace).
If you use your own company CA, place the CA certificate in PEM format as ldap-ca.pem next to setup-opbweb.sh before the setup (the setup moves it to certs/ldap/ldap-ca.pem and proposes it as CA file) or directly in certs/ldap/. The directory certs/ldap/ is mounted read-only in the container as /run/certs/ldap/; LDAP_CA_CERTS_FILE specifies the path inside the container. Without a CA file, oPBWeb verifies against the container’s default CAs.
Restart the stack so that the API reads the new values and the secret:
Sign in with a directory account. Under Users and roles, the account appears with the source Directory (LDAP) and the assigned role.
Groups and roles
- Each
LDAP_*_GROUPS variable contains one or more full group DNs, separated by ;. - oPBWeb compares the DNs case-insensitively, but otherwise exactly with the values from
memberOf. - The highest role wins: Administrator over Releaser over Packager over Viewer.
- No matching group, no access: sign-in fails with “No permission for oPBWeb (no matching group).” If none of the four variables is set, nobody can sign in via LDAP.
- oPBWeb does not resolve nested groups; only what
memberOf returns directly counts. The primary group of an AD account (usually “Domain Users”) is not included in memberOf.
What each role is allowed to do is described in Users and roles
.
Behavior of directory accounts
| Topic | Behavior |
|---|
| Role | comes from the groups and is set again on every sign-in; cannot be changed in the user interface |
| Role change in the directory | takes effect only at the next sign-in with password; a running session renews itself for up to 7 days without asking the directory |
| Password | is changed in the directory; Change password and Set password are not available for these accounts |
| Lock after failed attempts | the account lock applies only to local accounts; for directory accounts, the directory’s lockout policy applies. The limit for failed attempts per address also applies to LDAP sign-ins. |
| Deactivating in oPBWeb | possible; the account can then no longer sign in despite a valid directory password |
| Revoke access immediately | deactivate the account under Users and roles; this also ends running sessions |
Accounts with the same name: if a local account has the same name as a directory account, the local account always applies.
Troubleshooting
| Message at sign-in | Cause | Fix |
|---|
| “Directory service not reachable.” | connection, TLS or search with the service account failed | check the API log (see below): host, port, CA file, bind DN and password |
| “Wrong user name or password.” | wrong password, user not found, or the filter returns more than one entry | check filter and search base; test sign-in with the same account directly against the directory |
| “No permission for oPBWeb (no matching group).” | sign-in succeeded, but no group matches | compare the group DNs exactly with the user’s memberOf; check direct membership |
| “Account is disabled.” | account deactivated in oPBWeb | reactivate it under Users and roles |
The API writes connection and search errors to its log:
docker logs opbweb-api 2>&1 | grep -i "LDAP search failed"
Typical causes are a host name that does not match the certificate, a missing or wrong CA file (path inside the container, not on the host), an empty secrets/ldap_bind_password.txt, or a domain controller that cannot be reached from the Docker host.
To cross-check the search, use ldapsearch on a machine with the OpenLDAP tools. Example, adapt it to your values:
ldapsearch -H ldaps://dc01.example.local:636 \
-D "cn=svc-opbweb,ou=Dienstkonten,dc=example,dc=local" -W \
-b "ou=Benutzer,dc=example,dc=local" \
"(&(objectClass=user)(sAMAccountName=mmuster))" memberOf displayName mail
You can copy the memberOf values from the output directly into the LDAP_*_GROUPS variables.
Operation and Updates
Starting and stopping the stack, logs, health endpoints, updates, versioning and development stack
Audience: Technical administration
Overview
For day-to-day operation you need two scripts in the project directory: start.sh and stop.sh. This chapter also describes where to find logs and service status, how to update oPBWeb and what to keep in mind on the opsi side. Run all commands in the project directory.
Starting and stopping the stack
start.sh
./start.sh [--build] [--logs] [--dev | --test] [--lang de|en]
| Option | Effect |
|---|
| (none) | starts the production stack (docker-compose.yml) with the existing images |
--build | rebuilds your own images before starting |
--logs | follows the logs of all containers after starting (logs -f --tail=50); Ctrl+C only ends the output |
--dev | adds docker-compose.dev.yml (development stack, see below); only in a clone with the sources |
--test | adds docker-compose.test.yml: two opsi mocks as config servers Test and Production, see Test Environment |
--lang de or --lang en | language of the messages for this run (otherwise OPBWEB_LANG from .env) |
-h, --help | shows the help |
Sequence: the script aborts if .env is missing („.env fehlt – bitte ./setup-opbweb.sh ausführen (im Entwickler-Checkout: ./setup-opbweb.sh –dev).“ – .env is missing, please run ./setup-opbweb.sh, in a developer checkout ./setup-opbweb.sh –dev), calls scripts/init-secrets.sh, runs docker compose up -d and shows the status with docker compose ps. Running containers whose configuration has not changed are left untouched. start.sh does not pull new third-party images; for that, see Updating
.
stop.sh
The script has no options. It runs docker compose down with docker-compose.yml and the overlays present (docker-compose.dev.yml, docker-compose.test.yml) and thus stops production, dev and test services alike. Containers and networks are removed; the volumes with database and object storage are kept.
Restarting individual services
docker restart opbweb-worker
docker restart opbweb-frontend # e.g. after a certificate change
Restarting the worker interrupts running jobs. Check under Jobs beforehand whether installations are currently running.
Containers
| Container | Service | Note |
|---|
opbweb-frontend | nginx | web interface, manual, proxy |
opbweb-api | API | runs the migrations at startup |
opbweb-worker | worker | jobs, cron, opsi connection |
opbweb-db | MariaDB | |
opbweb-redis | Redis | |
opbweb-garage | Garage | object storage |
opbweb-garage-init | Garage setup | runs briefly on every start and exits with exit code 0 |
opbweb-opsi-mock | dev only | simulated opsiconfd (first opsi system) |
opbweb-opsi-mock2 | dev only | simulated opsiconfd (second opsi system) |
opbweb-frontend-dev | dev only | Vite dev server |
opbweb-manual-dev | dev only | Hugo preview of the manual |
opbweb-launcher-build | dev only | builds the launcher zips at startup and exits with exit code 0 |
opbweb-launcher-dev | dev only | serves the launcher zips |
docker compose ps shows the status (production stack); docker compose ps -a also includes exited containers such as garage-init.
Logs
docker logs -f --tail=100 opbweb-api
docker logs -f --tail=100 opbweb-worker
docker compose logs -f --tail=50 # all services
| Container | What you find there |
|---|
opbweb-api | migrations and uvicorn start, creation of the first admin, HTTP requests (one line per request), problems with LDAP sign-in |
opbweb-worker | job execution, opsi check (every 5 minutes by default), errors in connections to opsiconfd |
opbweb-frontend | choice of HTTP or HTTPS, requests and nginx errors |
opbweb-garage-init | setup of layout, key and bucket |
LOG_LEVEL in .env controls the verbosity of api and worker (DEBUG, INFO, WARNING, ERROR; default INFO). A change takes effect with the next ./start.sh.
For LDAP sign-in, the API logs why a sign-in fails: user not found (zero hits for the search filter) and wrong password at level INFO, no group mapped to a role at level WARNING. With LOG_LEVEL=WARNING or ERROR you therefore do not see the first two cases. For example:
docker logs opbweb-api 2>&1 | grep "LDAP login"
python-opsi and the HTTP client httpx only output warnings and errors. Their debug output is enabled only by DEBUG=true; this also starts the API with hot reload and is meant for development only, see Configuration
.
The complete log of every job is also available in the user interface under Jobs, see Jobs
.
Checking the status
| Endpoint | Sign-in | Response |
|---|
/health | no | static healthy from nginx; only checks whether nginx responds |
/api/health | no | {"status":"ok","version":"…"}; the API is reachable through nginx |
/api/health/details | yes (bearer token) | checks database, redis, storage, worker, opsi with ok, details and duration |
/api/system/database | yes, admin | rows and size per database table |
/api/system/storage | yes, admin | object storage usage per area, deduplication, unused and missing blobs |
/api/system/storage/objects | yes, admin | objects of an area with the package versions using them (basis of the bucket browser) |
| System status page | yes, all roles | the same checks as a table, refreshed every 10 seconds |
/api/health is suitable for simple monitoring:
curl -fsS https://opbweb.example.local/api/health
The detailed check evaluates as follows (the details follow the interface language):
| Check | Error if … |
|---|
| Database | no connection; otherwise the details show the MariaDB version and schema revision |
| Redis | no connection |
| Object storage | no connection or bucket missing (“Bucket ‘…’ is missing”) |
| Worker | no heartbeat (“no sign of life”) or last heartbeat older than 90 seconds |
| opsi server | not checked yet, no config server configured, or for at least one config server: connection failed, no admin rights, new CA certificate not yet accepted, or expiry warning |
Administrators also see the database table sizes, the object storage usage and the bucket browser on the system status page, see System status
.
Scheduled tasks
The worker runs these tasks itself; you do not need a cron entry on the host.
| Task | Schedule | Effect |
|---|
| Heartbeat | every 30 seconds | sign of life for the system status |
| opsi check | every 5 minutes (default) | connection, depot sync, CA comparison, expiry warning 60 days in advance |
| Cleanup of temporary files | daily at 03:17 (default) | deletes uploads/ and downloads/ older than 24 hours |
The times of the opsi check and the cleanup are set under Settings → Worker
; they apply without a restart.
The automatic task does not remove unused package files (blobs) or build results. An administrator does that when needed with Clean up … on the System status
page: first a dry run, then the confirmed deletion.
Updating
Back up the database and object storage, see Backup and Restore
. Database migrations cannot easily be undone.
Check under Jobs that no installations are running.
Get the new version, for example with git pull or with a new stack ZIP
unpacked over the installation. The ZIP contains no .env, secrets/ or certs/, they are kept.
Bring .env up to date:
./setup-opbweb.sh --updateonly
The script keeps all existing values, adds new variables with their default and creates new secrets; existing secrets stay unchanged. If you want to change values at the same time, run ./setup-opbweb.sh without an option.
Update the images and start the stack:
Locally built images:
--build uses the Docker build cache. Security updates of the base images and the system packages do not necessarily arrive that way. For a security update, build without cache and with fresh base images, then run ./start.sh:
docker compose build --pull --no-cache
Images from a registry: Enter the new image versions, either interactively with ./setup-opbweb.sh (questions about the image versions; the default is the previous version) or directly in BACKEND_IMAGE, FRONTEND_IMAGE and GARAGE_INIT_IMAGE in .env. --updateonly keeps the previous versions. With a versions.env next to it (stack ZIP), its versions are the defaults, for --updateonly too; unpacking a new ZIP and running --updateonly thus switches the images to its versions. Then:
docker compose pull
./start.sh
If you use the latest tags, docker compose pull and ./start.sh are enough.
The API runs alembic upgrade head automatically at startup. Check the log afterwards:
docker logs opbweb-api 2>&1 | head -50
In the user interface, check the versions in the status bar (“Frontend …”, “Backend …”) and the System status page.
start.sh does not automatically pull new versions of the third-party images (MariaDB 11.4, Redis 7, Garage v2.2.0) within their tags. Example, run when needed:
docker compose pull db redis garage
./start.sh
To also update the base images of your own images (node, nginx, python), build with docker compose build --pull and then start with ./start.sh. This only applies to locally built images.
Versioning
Backend and frontend have separate version numbers following the scheme MAJOR.MINOR.PATCH (milestone, phase, bug fix). The user interface shows both in the status bar; /api/health returns the backend version.
Versions are set with set-version.sh; the affected files are listed in version.yaml (backend: backend/src/core/version.py and backend/pyproject.toml, frontend: frontend/package.json via npm version).
| Call | Effect |
|---|
./set-version.sh | shows the current versions |
./set-version.sh 0.14.0 | sets all components (with confirmation; --force without) |
./set-version.sh --backend 0.13.1 | sets the backend only |
./set-version.sh --frontend 0.13.1 | sets the frontend only |
./set-version.sh --component <name> <version> | sets one component from version.yaml |
./set-version.sh --config <file> | uses a different configuration file |
A changed version file on the default branch triggers the build of the matching image in GitLab CI, see Build and Delivery
.
opsiconfd: admin-networks
The worker calls opsiconfd with admin rights. If the option admin-networks is set in the opsiconfd configuration, the worker’s source address must be within it; otherwise opsiconfd responds with HTTP 403.
Which address opsiconfd sees depends on where oPBWeb runs (derived from Docker’s network behavior; please verify in the opsiconfd log):
oPBWeb on the configserver itself: the worker’s address in the Docker network opbweb_opsi-net. To show the subnet:
docker network inspect opbweb_opsi-net --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
oPBWeb on a different host: usually the address of the Docker host, because Docker masquerades outgoing traffic.
./stop.sh removes the Docker networks; on the next start, opbweb_opsi-net may get a different subnet. Therefore either enter the entire address range from which Docker allocates its networks, or check the subnet after every restart. The opsi manual describes the syntax of the option; opsiconfd must be restarted after the change.
Development stack
The development stack is meant for further development, not for production. In a fresh developer checkout, first create .env (from .env.example, without questions) and then start with locally built images:
./setup-opbweb.sh --dev
./start.sh --dev --build
It adds hot reload (sources as bind mounts, DEBUG=true), two simulated opsiconfd instances, a preview of the manual and the launcher zips to the production stack. API and worker run with the image opbweb-backend:dev.
| What | Address |
|---|
| Vite dev server (user interface with hot reload) | http://localhost:3100 |
Manual preview (manual-dev, via the Vite proxy) | http://localhost:3100/manual/ |
Launcher zips (launcher-dev, via the Vite proxy) | http://localhost:3100/downloads/ |
| API with OpenAPI interface | http://localhost:8100/api/docs |
| nginx frontend | port from FRONTEND_HTTP_PORT or FRONTEND_HTTPS_PORT |
| MariaDB | port 3307 |
| Redis | port 6380 |
| Garage S3 | port 3910 |
opsiconfd mock (opsi-mock) | opsi-config.mock and depot1.mock, only in the Docker network |
second opsiconfd mock (opsi-mock2) | opsi-config2.mock and depot2.mock, only in the Docker network |
Each mock simulates a configserver and a depot, including install, uninstall and WebDAV. opbweb-opsi-mock is the first opsi system: as long as no config server is configured, the API registers it at startup as “opsiconfd-Mock” with the stage Test (URL http://opsi-config.mock:4447, user opsiadm, password mock, without TLS verification). opbweb-opsi-mock2 is a second, independent opsi system for tests with several config servers; it is not registered automatically. To use it in the user interface, add it under Depots with + (URL http://opsi-config2.mock:4447, user opsiadm, password mock, TLS verification off).
The end-to-end tests register both mocks as config servers themselves (as “t-Mock 1” and “t-Mock 2”, if not present yet), remove them again afterwards and work only on their depots. They do not assume that the mock is the only or the default config server, and they also run when a real opsi server is configured in addition. The manual preview does not refresh by itself; reload the page after a change.
Warning
The dev and production stacks use the same container names and the same volumes. They cannot run at the same time and share database and object storage. Therefore never start the development stack on a production host. To switch, stop with ./stop.sh and start again with or without --dev.
Backup and Restore
What you need to back up, how to back it up and in which order to restore
Audience: Technical administration
Overview
oPBWeb’s data lives in two places that belong together: the database holds all metadata, the object storage holds the file contents. The database refers to objects in the storage via SHA-256 checksums and keys (blobs/…, artifacts/…). A backup is only usable if both parts come from the same point in time and the matching secrets are included.
Note
The commands in this chapter are examples. Backing up the database with mariadb-dump and backing up the Garage volumes while the stack is stopped is the intended approach; the specific commands for volumes and restore are derived from it. Adapt paths and names to your environment and test the restore before you rely on it.
What to back up
| What | Where | Back up | Note |
|---|
| Database | volume opbweb_mariadb-data | yes, as a dump | packages, versions, users, jobs, audit log, snippets, config servers and depots, settings, encrypted opsi passwords |
| Object storage metadata | volume opbweb_garage-meta | yes, with the stack stopped | bucket, keys, object index |
| Object storage data | volume opbweb_garage-data | yes, with the stack stopped | contents of package files and built packages |
| Secrets | secrets/ | yes | without opsi_key_private.pem the opsi password is lost; without the other secrets, database and object storage no longer fit together |
| Configuration | .env | yes | |
| Certificates | certs/ (incl. certs/ldap/) | yes | TLS certificate and key, LDAP CA |
| Redis | volume opbweb_redis-data | no | only queue and short-lived status |
| S3 credentials | volume opbweb_garage-credentials | no | garage-init rewrites them |
Also note the oPBWeb version (Git commit or the versions from the status bar). A database can only be used with the same or a newer oPBWeb version.
Warning
Backups contain password hashes, the encrypted opsi password and, with secrets/, also the key for it. Store them only in a protected location, and do not keep secrets/ unencrypted next to the data if other people have access to the backup target.
Creating a backup
To make sure database and object storage have the same state, first stop the services that change data, then back up the database, and then back up the object storage with the stack stopped.
Check under Jobs that no jobs are running or waiting.
Create a protected backup directory:
Stop frontend, worker and API. Database and object storage keep running:
docker stop opbweb-frontend opbweb-worker opbweb-api
Back up the database:
docker exec opbweb-db sh -c 'mariadb-dump -uroot -p"$(cat /run/secrets/db_root_password)" --single-transaction opbweb' > backup/opbweb.sql
The command reads the root password inside the container from the Docker secret; it does not appear on the host’s command line.
Stop the stack:
Back up the two Garage volumes (example with an Alpine helper container):
docker run --rm -v opbweb_garage-meta:/v:ro -v "$PWD/backup":/b alpine tar czf /b/garage-meta.tgz -C /v .
docker run --rm -v opbweb_garage-data:/v:ro -v "$PWD/backup":/b alpine tar czf /b/garage-data.tgz -C /v .
Back up secrets, configuration and certificates:
tar czf backup/config.tgz secrets .env certs
Start the stack again:
Then transfer the contents of backup/ to your backup target. The downtime depends mainly on the size of the object storage.
If a short interruption of the whole stack is acceptable anyway, you can back up the volume opbweb_mariadb-data with the stack stopped, just like the Garage volumes, instead of creating a dump. In that case, the passwords from secrets/db_password.txt and secrets/db_root_password.txt, which are stored in the volume, must be kept together with the database.
Restoring
The order matters: first secrets and configuration, then the object storage, then the database, and finally the start.
Stop the stack.
Provide the program version. Check out the same oPBWeb version that belongs to the backup (or a newer one). If you use images from a registry, their versions are in the backed-up .env (BACKEND_IMAGE etc.).
Restore secrets, .env and certificates. Use the original files from the backup, not newly generated ones:
tar xzf backup/config.tgz
chmod 750 secrets && chmod 444 secrets/*
The files need permissions 444 so that the processes in the containers can read them; start.sh sets the permissions via init-secrets.sh on every start anyway.
Restore the object storage. Remove the existing Garage volumes including the S3 credentials and restore the backup:
docker volume rm opbweb_garage-meta opbweb_garage-data opbweb_garage-credentials
docker volume create opbweb_garage-meta
docker volume create opbweb_garage-data
docker run --rm -v opbweb_garage-meta:/v -v "$PWD/backup":/b alpine tar xzf /b/garage-meta.tgz -C /v
docker run --rm -v opbweb_garage-data:/v -v "$PWD/backup":/b alpine tar xzf /b/garage-data.tgz -C /v
You must remove the volume garage-credentials so that garage-init rewrites the credentials from the restored key. If an old volume remains, the credentials do not match the object storage. On the next start, the garage-init log shows “Credential files missing, writing from existing key…”.
Restore the database. Remove the database volume, start only the database and import the dump:
docker volume rm opbweb_mariadb-data
docker compose up -d db
docker compose ps db # wait until the status is "healthy"
docker exec -i opbweb-db sh -c 'mariadb -uroot -p"$(cat /run/secrets/db_root_password)" opbweb' < backup/opbweb.sql
On the first start with the empty volume, MariaDB creates the database opbweb and the application user with the passwords from secrets/. If you backed up the volume instead of a dump, restore it like the Garage volumes.
Start the stack.
The API runs any missing migrations if the program version is newer than the backup. It does not create a first administrator account, because the user table from the backup is populated.
Verify.
- System status: all services OK. As an administrator, check under Object storage that no missing blobs are reported; missing blobs indicate that database and object storage come from different points in time.
- Depots → config server: Test connection for each config server. If sign-in fails,
opsi_key_private.pem does not match the database; enter the password of the technical user again. - Open a package and download a file or a built package.
Jobs that were waiting or running at the time of the backup are not resumed by oPBWeb after the restore. Start them again if needed.
Consistency
| Situation | Consequence |
|---|
| Database older than object storage | surplus objects; harmless, Clean up … on the system status page removes them |
| Database newer than object storage | missing blobs or build results; affected files cannot be opened, affected versions must be rebuilt |
New key pair opsi_key_*.pem | opsi password unreadable; enter it again |
New db_password.txt or db_root_password.txt with a backed-up database volume | API and worker cannot reach the database |
Old volume garage-credentials with a restored object storage | access to the object storage fails; remove the volume and restart |
For regular backups, a fixed order in a script that runs all steps of the section Creating a backup
one after another helps.
Troubleshooting
Typical problems with cause and fix, plus an overview of important error messages
Audience: Technical administration
Overview
This chapter sorts typical problems by symptom. For each symptom you find the likely cause and the fix. At the end there is a table of important error messages. Messages of the server follow the interface language; this chapter quotes them as shown in the English interface. Messages of the shell scripts and container logs are German and are quoted with an English translation.
First places to look
| Source | Content |
|---|
| System status page | status of database, Redis, object storage, worker and opsi server, see System status |
| Jobs page | complete log of every job with error message, see Jobs |
docker compose ps -a | which containers are running and which have exited (with exit code) |
docker logs opbweb-api | startup, migrations, sign-in, LDAP |
docker logs opbweb-worker | jobs, opsi connection |
docker logs opbweb-garage-init | object storage setup |
| Status bar of the user interface | “Backend not connected” if the API does not respond; orange notice for problems with the opsi server |
Stack does not start
| Symptom | Cause | Fix |
|---|
start.sh reports „.env fehlt – bitte ./setup-opbweb.sh ausführen (im Entwickler-Checkout: ./setup-opbweb.sh –dev).“ (.env is missing, run the setup script) | no .env | ./setup-opbweb.sh (or cp .env.example .env) |
| API does not start, log: “Secret file not found: …” | secret file missing, for example because the stack was started with docker compose instead of ./start.sh | use ./start.sh or run ./scripts/init-secrets.sh |
| API exits after about 60 seconds, log: “Database not reachable: …” | MariaDB not ready, or the password does not match the volume | check docker logs opbweb-db; if db_password.txt was changed, see Configuration |
| api, worker and frontend do not start, garage-init has a non-zero exit code | object storage setup failed | docker logs opbweb-garage-init; “ERROR: No admin token found” means secrets/garage_admin_token.txt is missing |
| garage-init keeps reporting “Not ready yet, retrying in 2s…” | Garage does not start or the admin token does not match | docker logs opbweb-garage; do not change the token in secrets/ afterwards |
| API log: “No users and no ADMIN_PASSWORD configured - no initial admin created” | secrets/admin_password.txt is empty or missing while the user table is empty | fill the file or let init-secrets.sh create it, restart the stack |
Sign-in
“Wrong user name or password.” For local accounts, every failed attempt counts. After 5 failed attempts from the same address the account is locked for that address for 15 minutes: “Account locked until HH:MM.” The user can still sign in from other addresses; the account is locked for everyone only after 50 failed attempts. After 20 failed attempts from one address (across all accounts) oPBWeb answers “Too many failed sign-ins from this address.” An administrator removes the lock under Users and roles with Unlock; Set password also unlocks the account. See Users and roles
.
Password of the only administrator forgotten. There is no tool for resetting it without signing in. Another administrator (local or via an LDAP group) can set the password. So create a second administrator account early on. secrets/admin_password.txt only applies on the very first start.
“Account is disabled.” The account is deactivated in oPBWeb. An administrator reactivates it under Users and roles.
LDAP messages (“Directory service not reachable.”, “No permission for oPBWeb (no matching group).”): see LDAP Sign-in
.
Signed out again after reloading the page or after about 15 minutes. The session is renewed via the cookie opbweb_refresh. If COOKIE_SECURE=true is set while oPBWeb is only reachable via HTTP, the browser does not store the cookie. Set COOKIE_SECURE to match the protocol and restart with ./start.sh.
“Session expired.” The token is invalid or was revoked, for example after signing out, after 7 days without renewal, or after secrets/jwt_secret_key.txt was changed. Sign in again.
Connection to the opsi server
The opsi server row on the system status page shows the status, with one entry per config server; in addition, there is the colored dot in front of each config server in the Depots view. The worker checks all config servers every 5 minutes; after a change you can check immediately under Depots → config server with Test connection. The setup is described in Config servers
.
| Symptom | Cause | Fix |
|---|
| “not checked yet” | worker has just started | wait for the next check (default 5 minutes) or use Test connection |
| “No config server configured (Depots → add config server).” | no config server stored | add a config server under Depots with + |
| “No CA certificate is stored for the opsi server (Depots → config server).” | TLS verification without CA | Fetch from server, compare the fingerprint, Trust, Save |
| TLS or certificate error despite CA | URL with an IP address instead of a host name; the opsiconfd certificate is issued for the FQDN | enter the URL with the FQDN, e.g. https://opsi.example.local:4447 |
| “The server delivers a different CA certificate than the stored one (rotation?)”, on the system status page “Server delivers a new CA certificate, please accept it under Depots” | opsi has renewed its CA | Accept new certificate for the affected config server under Depots; until then, connections fail as soon as the server uses the new CA |
| “CA certificate expires in N days” | warning 60 days before expiry | have the CA renewed on the opsi server, then accept it |
| “… has no opsi admin rights”, on the system status page “Login without admin rights” | technical user is not an opsi admin | add the user to the opsi admin group |
| HTTP 403 on calls despite correct password | admin-networks in opsiconfd excludes the worker’s address | see admin-networks |
| “Config server not reachable: …” | network, DNS or firewall between worker and port 4447 | check reachability from the worker (see below) |
| “A config server with this URL is already configured: …” | the same URL is to be added a second time | select the existing config server in the list |
| in the job log “Depot … already belongs to config server … and is skipped” | the same opsi server is configured under two URLs | remove the duplicate config server |
| Sign-in to the opsi server fails after a restore | opsi_key_private.pem does not match the database | enter the password of the technical user again |
Check reachability from the worker container (example; -k only for this test, because the opsi CA is not known inside the container):
docker exec opbweb-worker curl -sk -o /dev/null -w '%{http_code}\n' https://opsi.example.local:4447/
An HTTP status number shows that name resolution and connection work. An error such as “Could not resolve host” or a timeout points to DNS or the firewall.
Building
Build errors are shown in the log of the Build job under Jobs.
| Message | Fix |
|---|
| “Script file (action) is missing in CLIENT_DATA.” | The script entered in the product data is missing from the package. Upload the file or correct the entry. |
| “A build is already running for this version.” | Wait for the running build to finish. |
| “The version was changed during the build. Please build again.” | Something was saved during the build. Click Build again. |
| “Version X is released; use “Back to draft” to rebuild.” | Released versions are read-only. Use Back to draft or New version. |
| “Archive could not be created: …” | Check the details in the message; often there is no free space on the host or the object storage is not reachable. |
| “The package cannot be built like this.” | The check before building found errors; each error is listed separately in the job log. Correct them and click Build again. |
Installing and uninstalling
| Symptom or message | Cause | Fix |
|---|
| Install … is grayed out, or “The version has not been built yet or was changed since the last build. Please build it first.” | no current build | Build again |
| “The depot depot (stage) requires the role Releaser.” | production depot, Packager role | ask a Releaser, or check the depot stage, see Depot management |
| “Depot depot is disabled or no longer exists.” | depot deactivated in oPBWeb or no longer reported by the configserver | Sync under Depots, activate the depot if needed |
| “Installation failed on: …” | opsiconfd error during upload or depot_installPackage | check the job log and the opsiconfd log |
| Product marked as locked on the depot | an aborted installation left the lock behind | under Depots → Installed products, select it and click Unlock (n), or in the install dialog use Force installation, even if the product is locked |
| “[depot] Depot has been busy with another job for too long.” | another job has held the depot for more than 6 hours | check the running job under Jobs; if the worker hangs, docker restart opbweb-worker |
| Upload of large packages aborts | time limit for upload and installation reached | increase OPSI_INSTALL_TIMEOUT_SECONDS; with many depots also JOB_TIMEOUT_SECONDS |
| Job ends with “Cancelled: job time limit exceeded or worker stopped.” | job ran longer than JOB_TIMEOUT_SECONDS or the worker was restarted | raise the limit or start the job again; a version released by it and installed nowhere is a draft again |
| Version is released although the installation failed | oPBWeb releases the version when the installation job starts | intended; for changes use Back to draft or New version |
Worker not reachable
Symptoms: On the system status page, the Worker row reports “no sign of life” or “last sign of life N s ago”; jobs stay in the waiting state; the file manager under Depots reports “Worker not reachable.” or “The worker does not respond.”
Causes and fix:
- Check whether the container is running:
docker compose ps worker. - Read the log:
docker logs --tail=200 opbweb-worker. Common causes are missing secrets, no connection to Redis, or an error at startup. - Restart the worker:
docker restart opbweb-worker. - “The worker does not respond.” can also mean that the worker is busy. It runs at most 10 jobs at the same time; file manager calls wait at most 60 seconds. Wait for running jobs to finish and try again.
Object storage
| Symptom | Cause | Fix |
|---|
| System status, object storage: “Bucket ‘packages’ is missing” | garage-init did not run or did not succeed | docker logs opbweb-garage-init, then ./start.sh |
| Access to the object storage denied, for example after a restore | S3 credentials in the volume garage-credentials do not match the key in Garage | ./stop.sh, docker volume rm opbweb_garage-credentials, ./start.sh |
| Files cannot be opened, system status reports missing blobs | database and object storage come from different points in time | restore a consistent backup, see Backup and Restore
; rebuild affected versions |
| Disk space on the host is running low | old blobs and build results of deleted packages | Clean up … on the System status
page; check volume usage with docker system df -v |
Uploads and time limits
| Symptom | Cause | Fix |
|---|
| Upload aborts with HTTP 413 | file larger than 20 GB (client_max_body_size) | make the file smaller or get it into the package another way, for example via the workbench |
| “Network error while uploading” | connection between browser and server dropped, or a proxy in between has its own limit | check upstream proxies and firewalls for size and time limits |
| Long requests abort after about 11 minutes | nginx timeout of 660 seconds without data transfer to the API | long operations run as jobs; set similar timeouts on your own proxies in front |
The worker deletes uploaded files that were not processed after 24 hours.
HTTPS and certificates
| Symptom | Cause | Fix |
|---|
| HTTPS does not respond, only HTTP | certs/fullchain.pem or certs/key.pem is missing or misnamed | check the file names; the opbweb-frontend log shows „Kein TLS-Zertifikat – HTTP auf Port 80“ (no TLS certificate – HTTP on port 80) |
| Frontend does not start after a certificate change | certificate and key do not match or are damaged | docker logs opbweb-frontend; check the pair |
| New certificate is not served | nginx reads certificates only at startup | docker restart opbweb-frontend |
| HTTP no longer works in the browser after switching back from HTTPS | HSTS header (one year) stored in the browser | stay with HTTPS or delete the HSTS entry in the browser |
| Signed out after reload | COOKIE_SECURE does not match the protocol | see Sign-in |
Important error messages
| Message | Meaning | Fix |
|---|
| Not signed in. | no token sent | sign in again |
| Session expired. | token invalid or revoked | sign in again |
| Account not available. | account deleted or deactivated | ask an administrator |
| No permission for this action. | role is insufficient (HTTP 403) | check the role |
| Wrong user name or password. | wrong credentials or unknown user | check the input; lock after 5 failed attempts from the same address |
| Too many failed sign-ins from this address. | 20 failed attempts from one address within 15 minutes | wait |
| Account locked until HH:MM. | local lock after failed attempts | wait or Unlock |
| Account is disabled. | account deactivated in oPBWeb | activate the account |
| Directory service not reachable. | LDAP connection or search failed | LDAP Sign-in |
| No permission for oPBWeb (no matching group). | LDAP sign-in without a matching group | check the group mapping |
| The last active administrator cannot be removed. / … cannot be deleted. | protection of the last administrator | create another administrator first |
| Version X is released (installed) and read-only. … | editing a released version | Back to draft or New version |
| Script file (action) is missing in CLIENT_DATA. | script missing during build | add the file |
| The version has not been built yet or was changed since the last build. Please build it first. | installing without a current build | Build |
| The depot depot (stage) requires the role Releaser/Packager. | depot stage rule | check the role or the depot stage |
| [depot] Depot has been busy with another job for too long. | depot lock longer than 6 h | check for a hanging job or worker |
| Installation failed on: … | opsiconfd error | job log, opsiconfd log |
| Config server not reachable: … | network to the opsi server | check reachability from the worker |
| No config server configured (Depots → add config server). | no config server configured | add a config server under Depots |
| No CA certificate is stored for the opsi server (Depots → config server). | CA missing | Fetch from server |
| A config server with this URL is already configured: name | duplicate URL | use the existing config server |
| The server delivers a different CA certificate than the stored one (rotation?) | CA rotation | Accept new certificate |
| user has no opsi admin rights | technical user without admin rights | grant the rights in opsi |
| {method}: HTTP {code} … | opsiconfd refuses (text from opsiconfd, not translated); for 403 usually admin-networks | admin-networks |
| Worker not reachable. / The worker does not respond. | worker stopped or busy | Worker not reachable |
| Download link invalid or expired. | link older than 2 minutes | start the download again |
| Deleting requires a dry run. / The dry run is missing or older than one hour. Please check again. | cleanup without a valid dry run | start a new dry run |
| Imports or builds are running. Please start again after they have finished. | cleanup blocked | start again later |
| Unexpected error: … | unhandled error in the job | check the job log and the worker log |
| Secret file not found: path (API log) | secret file missing at startup | ./start.sh or init-secrets.sh |
| Database not reachable: … (API log) | database not reachable after 60 s | opbweb-db log |
Build and Delivery
Building images, GitLab CI pipeline, container scanning and generating the manual as website and PDF
Audience: Technical administration
Overview
oPBWeb consists of three images of its own: backend (for api and worker), frontend (nginx with user interface and manual) and garage-init. You either build them locally with ./start.sh --build or let GitLab CI build them and push them to the project registry. The CI also generates the manual as PDF. This chapter describes the images, the pipeline and how to build the manual locally.
Images
All Dockerfiles use the project directory as build context. Among other things, .dockerignore keeps secrets/, certs/, .env, node_modules/ as well as the Hugo output and manual/.doc-state/ out of the context.
Backend
backend/Dockerfile, one image for api and worker:
| Stage | Content |
|---|
base | python:3.13-slim, system packages (including msitools for MSI information), Python dependencies from requirements.txt, plus python-opsi directly from the GitHub tag 4.3.32.1 (build argument PYTHON_OPSI_REF), because it is not on PyPI |
development | additional development tools; in the dev stack, the sources come in as a bind mount |
production | without compiler and Git, application under /app, runs as user appuser (UID 1000), port 8000 |
Locally, this produces opbweb-backend:latest (production) or opbweb-backend:dev (dev stack).
Frontend
frontend/Dockerfile:
| Stage | Content |
|---|
development | node:22-alpine with the Vite dev server (dev stack only) |
builder | node:22-alpine; npm ci and npm run build produce the Vue user interface |
manual | hugomods/hugo:exts-0.154.5; builds this manual from manual/ with hugo --minify --baseURL /manual/ |
launcher | golang:1.24-alpine; builds the launcher for opsi-setup-detector (tools/opb-launcher/build.sh), one zip file per platform |
production | nginx:stable-alpine (with apk upgrade), nginx configuration from frontend/nginx/, user interface under /usr/share/nginx/html, manual under /usr/share/nginx/html/manual, launcher zips under /usr/share/nginx/html/downloads (address /downloads/) |
The manual is therefore an integral part of the frontend image. A change to the manual appears in the web interface only with a newly built frontend image.
garage-init
garage/Dockerfile.garage-init: alpine:3.21 with bash, jq and curl plus the script garage/init-garage.sh. Locally, the image is called opbweb-garage-init:1.0.0. The version is fixed and only changes when the Dockerfile or the script changes.
Network access during the build
For a build, the host needs access to Docker Hub (base images), GitHub (python-opsi, Hugo modules), PyPI, the npm registry and the Go module proxy proxy.golang.org (Docsy). The launcher needs no third-party Go modules.
GitLab CI pipeline
The pipeline is defined in .gitlab-ci.yml. The jobs run on runners with the tag dind and build with Docker-in-Docker.
Stages and jobs
| Stage | Job | Result |
|---|
build | build-backend | backend:<backend version> and backend:latest |
build | build-frontend | frontend:<frontend version> and frontend:latest (with manual) |
build | build-garage-init | garage-init:1.0.0, garage-init:latest and garage-init:<short commit SHA> |
scan | container_scanning | vulnerability report for backend:<version> |
scan | container_scanning-frontend | vulnerability report for frontend:<version> |
release | build-manuals | PDFs of the manual and the release “manuals-latest” |
release | build-launcher | launcher zips as generic package opb-launcher/<launcher version> (after go vet and go test) |
release | stack-release | stack ZIP
oPBWeb-Stack-<pipeline no>.zip as generic package stack/<pipeline no> and release “stack-latest” |
The pipeline reads the versions from backend/src/core/version.py and frontend/package.json. The images end up in the project registry under <registry>/<project path>/backend, …/frontend and …/garage-init (path in lowercase). The registry address is set as CI_REGISTRY in .gitlab-ci.yml.
Stack ZIP
The job stack-release packs everything a Docker host needs to set up and start with the registry images into oPBWeb-Stack-<pipeline no>.zip, without sources:
| Content | Purpose |
|---|
setup-opbweb.sh, start.sh, stop.sh, scripts/init-secrets.sh | setup, start and stop |
docker-compose.yml, garage/garage.toml | the stack |
versions.env | registry prefix and image versions of this pipeline; setup-opbweb.sh takes them as defaults |
.env.example | overview of all variables (the setup does not need it) |
docker-compose.test.yml, tools/opsi-mock/, START-TEST-ENVIRONMENT_de.md/_en.md | test environment
with two opsi mocks |
QUICKSTART_de.md/_en.md, README.md | short guide to putting it into operation |
docker-compose.dev.yml is left out on purpose: the development stack mounts the sources. Without sources, setup-opbweb.sh offers no local build and start.sh --dev stops with a message. The ZIP is stored in the generic package stack/<pipeline no> of the project registry and linked from the release oPBWeb Stack (tag stack-latest), which every run recreates.
Using the images from the registry
The target host then does not need to build the images. In ./setup-opbweb.sh, choose r at the images question and enter the registry prefix (default: the registry of this CI, a different one via OPBWEB_REGISTRY) and the versions you want. The script writes BACKEND_IMAGE, FRONTEND_IMAGE and GARAGE_INIT_IMAGE to .env. Then pull the images (sign in with docker login first if the registry requires it) with docker compose pull and start with ./start.sh without --build. Details are in Installation
and Configuration
.
When each job runs
On the default branch of the project (set in GitLab under Settings → Repository, e.g. master or main), the jobs start automatically when their triggers change:
| Job | Trigger on the default branch |
|---|
build-backend, container_scanning | backend/src/core/version.py |
build-frontend, container_scanning-frontend | frontend/package.json |
build-garage-init | garage/Dockerfile.garage-init, garage/init-garage.sh |
build-manuals | manual/content/, manual/hugo.toml, manual/layouts/, manual/assets/, manual/static/, manual/scripts/, manual/build-pdf.sh |
build-launcher | tools/opb-launcher/version.json |
stack-release | backend or frontend version, docker-compose.yml, docker-compose.test.yml, setup-opbweb.sh, start.sh, stop.sh, scripts/init-secrets.sh, garage/garage.toml, tools/opsi-mock/opsi_mock.py, START-TEST-ENVIRONMENT_*.md, QUICKSTART_*.md |
A new backend or frontend image is therefore typically created by setting a new version with set-version.sh, see Operation and Updates
. A change to the manual alone produces new PDFs but no new frontend image; for that you need a new frontend version or a manual run of build-frontend.
Starting the pipeline manually
When you start a pipeline manually in GitLab, you choose what should run with the input run_job:
run_job | Effect |
|---|
all (default) | each job follows its rules for the default branch (see above); on other branches the pipeline stays empty |
build-core | all three image builds |
security-scan | both container scans |
build-backend | backend image only |
build-frontend | frontend image only |
build-garage-init | garage-init image only |
build-manuals | manual PDFs only |
build-launcher | launcher zips only |
stack-release | stack ZIP only |
The scans check the image with the current version number from the registry. It must therefore have been built and pushed beforehand.
Container scanning
The scan jobs use the GitLab template Jobs/Container-Scanning.gitlab-ci.yml with the threshold HIGH. The reports are available as job artifacts (gl-container-scanning-report-backend.json, gl-container-scanning-report-frontend.json) and appear in GitLab’s security reports.
Manual PDFs
The job build-manuals runs in the image python:3, installs WeasyPrint, Hugo extended version 0.154.5 (variable HUGO_VERSION), Go, Node.js and the GitLab release-cli, and then calls manual/build-pdf.sh output. It produces one PDF file per part and language:
| Part | German | English |
|---|
| Installation | oPBWeb-Installation-<Version>.pdf | oPBWeb-Installation-Guide-<Version>.pdf |
| User Guide | oPBWeb-Benutzerhandbuch-<Version>.pdf | oPBWeb-User-Guide-<Version>.pdf |
| Functional Administration | oPBWeb-Fachl-Administration-<Version>.pdf | oPBWeb-Functional-Administration-<Version>.pdf |
| Technical Administration | oPBWeb-Techn-Administration-<Version>.pdf | oPBWeb-Technical-Administration-<Version>.pdf |
<Version> is the frontend version. The job uploads the PDFs to the generic package manuals/latest in the project registry; it deletes files with the same name first. It then recreates the release manuals-latest („oPBWeb Handbücher“ – oPBWeb manuals) with one link per PDF („DE: Installation“, „EN: User Guide“ and so on) and the build date, pipeline number and frontend version in the description. In addition, the PDFs are kept as job artifacts for 30 days.
Building the manual locally
The sources are in manual/ (Hugo with the Docsy theme as a Hugo module). German texts are in manual/content/de/, English texts in manual/content/en/, images under manual/static/images/<language>/. You do not need to install anything on the host; all methods run in containers.
Preview in the dev stack
The development stack starts the service manual-dev (hugo server in the image hugomods/hugo:exts-0.154.5). You reach the preview via the Vite proxy:
http://localhost:3100/manual/
Live reload is disabled; reload the page after a change. If there are problems, docker logs opbweb-manual-dev helps. The dev stack is described in Operation and Updates
.
Building the website as in the image
The frontend image builds the manual itself. To check it without building an image, you can run the same command in a container (example):
docker run --rm -v "$PWD/manual":/src -w /src hugomods/hugo:exts-0.154.5 \
sh -c 'npm ci --no-audit --no-fund && hugo --minify --baseURL /manual/'
The result is in manual/public/. The directory is excluded from Git and from the Docker build.
Building the PDFs
manual/build-pdf.sh needs Hugo extended, Go, npm and Python with WeasyPrint. Run it in a container, as the CI does. The script reads the versions from backend/ and frontend/, so mount the whole project directory. Example for an x86-64 host, based on the CI job:
docker run --rm -v "$PWD":/work -w /work python:3 bash -c '
pip install --quiet weasyprint &&
apt-get update -qq && apt-get install -y -qq wget git golang nodejs npm > /dev/null &&
wget -q https://github.com/gohugoio/hugo/releases/download/v0.154.5/hugo_extended_0.154.5_linux-amd64.deb &&
dpkg -i hugo_extended_0.154.5_linux-amd64.deb > /dev/null &&
manual/build-pdf.sh output'
The PDFs are then in output/. On an ARM machine, use the matching Hugo package (linux-arm64) or start the container with --platform linux/amd64. The files created in the container are owned by root.
For PDF printing, Hugo also generates Docsy’s print output; manual/scripts/combine_print_html.py combines it into one HTML file with a title page per part and language, which WeasyPrint converts into a PDF.
Hugo and Docsy versions
Docsy is pinned to v0.15.0 in manual/go.mod, matching Hugo 0.154.5, which the CI uses for the PDFs. Update Docsy only together with a matching Hugo version, and check the website and PDFs afterwards.
Note
The Hugo version is defined in three places: in the frontend Dockerfile (hugomods/hugo:exts-0.154.5), in the manual-dev service in docker-compose.dev.yml, and as HUGO_VERSION in .gitlab-ci.yml. Always change it in all three places together, otherwise the website and the PDFs will differ.