[vibe](../../README.md) > [Guidebooks](../README.md) > [ERP](README.md) > **Deployment** # Deployment > **Status:** ✅ Active > **Last Updated:** 2026-06-23 > **Upstream:** [ERP hub](README.md) · [Applications hub](../applications/README.md) · [01 · factory](../lab-ecosystem/01-factory.md) > **Downstream:** [Backup & recovery](backup-and-recovery.md) · [Operations](operations.md) > **Related:** [tools secrets-and-vso](../tools/secrets-and-vso.md) · [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) · [factory ci-apply-flow](../factory-provisioning/opentofu/ci-apply-flow.md) · [naming-conventions](../lab-ecosystem/naming-conventions.md) · [webapp](../applications/webapp.md) This page maps how **erp** is deployed: the chart that wraps the **upstream Dolibarr image**, the runtime trick that makes a MySQL-assuming application speak **PostgreSQL**, the **50Gi document PVC** that holds every business record, the Vault CRDs that feed it credentials, and the OpenTofu + CI that declare its Vault objects. It is the most data-critical app in the lab; the `iac/` runs through the same `tofu apply` pipeline as every other app — see [factory ci-apply-flow](../factory-provisioning/opentofu/ci-apply-flow.md). ## 1 · App & image erp is **Dolibarr** pulled straight from the upstream `dolibarr/dolibarr` Docker Hub image — there is **no repo-built image** and no `Dockerfile`. The chart adapts the upstream container at runtime instead of forking it. | Field | Value | Source | |---|---|---| | Application | Dolibarr ERP/CRM (PHP / Apache) | [chart/Chart.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/Chart.yaml) | | Version | **22.0.4** (chart `appVersion: "22.0.4"`) | [chart/Chart.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/Chart.yaml) | | Image | `dolibarr/dolibarr:22.0.4` — upstream, `pullPolicy: IfNotPresent` | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | Image tag | `image.tag` empty → defaults to chart `appVersion` (`{{ .Values.image.tag \| default .Chart.AppVersion }}`) | [chart/templates/deployment.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/deployment.yaml) | | Served at | `https://erp.arcodange.lab` (internal only) | [chart/templates/config.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/config.yaml) | | Container command | `["/bin/bash", "/usr/local/bin/custom-entrypoint.sh", "apache2-foreground"]` | [chart/templates/deployment.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/deployment.yaml) | > [!NOTE] > Because erp consumes an upstream image, there is **no `docker-build-and-push` workflow** in `.gitea/workflows/` — unlike [webapp](../applications/webapp.md), which builds and pushes its own image. erp's only workflow is the OpenTofu/Vault one (see [§8](#8--ci--vaultyaml)). ## 2 · Postgres, not MySQL Dolibarr classically assumes MySQL, but erp runs on **PostgreSQL**. Two pieces make that work, both at startup, both inside the [custom entrypoint](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/scripts/custom_entrypoint.sh) which wraps the upstream `docker-run.sh`: 1. **MySQL → psql rewrite.** When `DOLI_DB_TYPE == "pgsql"`, the entrypoint `sed`s the upstream `/usr/local/bin/docker-run.sh` in place, replacing its `mysql -u ... < ${file}` SQL invocation with `PGPASSWORD=... psql -U ... -h ... -p ... -d ... < ${file}`. 2. **Apache `ServerName`.** It strips the scheme from `DOLI_URL_ROOT` and sets the Apache `ServerName` in `000-default.conf` (and appends to `apache2.conf`) so the vhost matches `erp.arcodange.lab`. 3. It then `exec`s the original `docker-run.sh "$@"` (i.e. `apache2-foreground`). The non-secret database wiring lives in the `erp-config` ConfigMap, injected via `envFrom`: | Env var | Value | Meaning | |---|---|---| | `DOLI_DB_TYPE` | `pgsql` | Selects PostgreSQL — triggers the entrypoint rewrite | | `DOLI_DB_HOST` | `pgbouncer.tools` | Connects through the [tools pgbouncer pooler](../tools/secrets-and-vso.md) | | `DOLI_DB_HOST_PORT` | `5432` | Pooler port | | `DOLI_DB_NAME` | `erp` | The per-app database (provisioned by [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md)) | | `DOLI_URL_ROOT` | `https://erp.arcodange.lab` | Drives the Apache `ServerName` | | `DOLI_ENABLE_MODULES` | `Societe,Facture` | Third-parties + invoicing modules | | `DOLI_COMPANY_NAME` | `Arcodange` | Seeded company name | | `DOLI_COMPANY_COUNTRYCODE` | `FR` | Seeded country | | `PHP_INI_DATE_TIMEZONE` | `Europe/Paris` | PHP timezone | | `DOLI_AUTH` | `dolibarr` | Native Dolibarr auth | | `DOLI_CRON` | `0` | In-container cron disabled | `DOLI_DB_USER` / `DOLI_DB_PASSWORD` are **not** in the ConfigMap — they come from Vault (see [§5](#5--vault-crds)). > [!WARNING] > The psql rewrite is a **textual `sed` against an upstream file**. If a future Dolibarr image changes the exact `mysql ... < ${file}` line in `docker-run.sh`, the substitution silently stops matching and SQL imports fall back to `mysql` (which is absent) — startup SQL then fails. Re-verify the entrypoint pattern whenever the `appVersion` is bumped. ## 3 · Persistence — the document PVC A single PVC named `erp` holds every business record. It is the most important object in the chart. | Field | Value | Source | |---|---|---| | Name | `erp` (`erp.fullname`) | [chart/templates/pvc.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/pvc.yaml) | | Access mode | `ReadWriteMany` (RWX) | [chart/templates/pvc.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/pvc.yaml) | | Size | `50Gi` | [chart/templates/pvc.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/pvc.yaml) | | StorageClass | `longhorn` | [chart/templates/pvc.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/pvc.yaml) | | Retention | annotation `helm.sh/resource-policy: keep` — survives a `helm uninstall` | [chart/templates/pvc.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/pvc.yaml) | The Deployment mounts the **same PVC** at three paths via `subPath`: | Mount path | subPath | Holds | |---|---|---| | `/var/www/documents` | `documents` | **Invoices, attachments, generated PDFs — the critical business data** | | `/var/www/html/custom` | `custom` | Custom/installed Dolibarr modules | | `/var/backups` | `backups` | In-pod backup landing area | > [!CAUTION] > **Losing this PVC loses all business documents.** `/var/www/documents` contains the only copy of uploaded invoices, attachments, and generated PDFs — these are real accounting records, not regenerable cache. The `helm.sh/resource-policy: keep` annotation protects it from a chart uninstall, but it does **not** protect against a Longhorn-volume loss or a node failure. Treat the PVC as primary data and rely on [Backup & recovery](backup-and-recovery.md) for off-volume copies. ## 4 · Chart shape | Aspect | Value | Source | |---|---|---| | `replicaCount` | **1** (single replica) | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | Autoscaling | **disabled** (`autoscaling.enabled: false`; no HPA rendered) | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | Service | `ClusterIP`, port `80` → `targetPort: http` | [chart/templates/service.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/service.yaml) | | Ingress host | `erp.arcodange.lab`, path `/` (`Prefix`) | [chart/templates/ingress.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/ingress.yaml) | | Ingress entrypoint | Traefik `websecure` + `router.tls: "true"` | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | TLS cert | `certresolver: letsencrypt`, domain `arcodange.lab` / SAN `erp.arcodange.lab` | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | Middleware | `localIp@file` — **internal only**, no public `.fr` host | [chart/values.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/values.yaml) | | revisionHistoryLimit | `5` | [chart/templates/deployment.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/deployment.yaml) | > [!WARNING] > **Single replica on an RWX PVC.** `replicaCount: 1` with autoscaling off means erp has **no redundancy** — a node or pod failure is a full outage until rescheduled. A credential rotation or config change triggers a rollout that briefly takes the only pod down. This is deliberate for a stateful, low-traffic internal app, but do not raise the replica count without first confirming Dolibarr tolerates concurrent writes to the shared `documents` volume. The Deployment carries `configmap-hash` / `configmap2-hash` / `configmap3-hash` annotations (sha256 of the three ConfigMaps) so a change to config or the init scripts forces a pod roll. ## 5 · Vault CRDs erp cannot start without VSO-injected credentials. Three CRDs (from the chart) wire it to Vault — see [tools secrets-and-vso](../tools/secrets-and-vso.md) for the VSO runtime. | CRD | Name | What it does | |---|---|---| | `VaultAuth` | `auth` | Kubernetes auth — `mount: kubernetes`, `role: erp`, ServiceAccount `erp`, audience `vault`. Every other CRD references it via `vaultAuthRef: auth`. | | `VaultStaticSecret` | `vault-kv-app` | `type: kv-v2`, `mount: kvv2`, `path: erp/config` → k8s Secret **`secretkv`**, `refreshAfter: 24h`. Injected via `envFrom` `secretRef`. Holds `DOLI_ADMIN_LOGIN`, `DOLI_ADMIN_PASSWORD`, `DOLI_INSTANCE_UNIQUE_ID`. | | `VaultDynamicSecret` | `vso-db` | `mount: postgres`, `path: creds/erp` → k8s Secret **`vso-db-credentials`** (rotating DB user/password). `rolloutRestartTargets` the erp Deployment so a rotation rolls the pod. `DOLI_DB_USER` / `DOLI_DB_PASSWORD` are wired into the pod via `secretKeyRef`. | Credential delivery in the Deployment: - `envFrom: secretRef: secretkv` — static admin config + instance UUID. - `env: DOLI_DB_USER` / `DOLI_DB_PASSWORD` ← `secretKeyRef` on `vso-db-credentials` (`username` / `password`). | Sources | [chart/templates/vaultauth.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/vaultauth.yaml) · [vaultsecret.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/vaultsecret.yaml) · [vaultdynamicsecret.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/templates/vaultdynamicsecret.yaml) | |---|---| ## 6 · Init scripts (mounted from ConfigMaps) Three scripts ship in `chart/scripts/` and are mounted into the pod via ConfigMaps. The entrypoint runs at container start; the `before-starting.d/` scripts run before Apache. | Script | Mounted at | Role | |---|---|---| | [custom_entrypoint.sh](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/scripts/custom_entrypoint.sh) | `/usr/local/bin/custom-entrypoint.sh` (ConfigMap `dolibarr-custom-entrypoint-script`) | Wraps `docker-run.sh`: MySQL→psql `sed` rewrite + Apache `ServerName` from `DOLI_URL_ROOT` (see [§2](#2--postgres-not-mysql)) | | [update_conf_db_credentials.sh](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/scripts/update_conf_db_credentials.sh) | `/var/www/scripts/before-starting.d/` (ConfigMap `dolibarr-before-start-scripts`) | `sed`s the Vault-injected `DOLI_DB_USER` / `DOLI_DB_PASSWORD` into Dolibarr's `conf.php` at startup, so the running app uses the freshly rotated creds | | [update_ownership.sql](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/chart/scripts/update_ownership.sql) | `/var/www/scripts/before-starting.d/update_table_ownership.sql` | `REASSIGN OWNED BY` the current `public`-schema owner → `erp_role`. Run if you hit read-only-filesystem / permission errors after a credential change | > [!CAUTION] > **The ownership SQL must run after the DB role behind the dynamic creds changes.** Because `postgres/creds/erp` mints a **new** Postgres user on each rotation, freshly created tables can end up owned by a transient user. If the in-pod `update_table_ownership.sql` cannot write its temp file (`Read-only file system`), it is skipped and Dolibarr eventually loses query rights once Vault rotates creds. The fix is to run that SQL by hand against the `erp` database — see [Operations](operations.md). The script reassigns ownership to the stable **`erp_role`** created by [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md). ## 7 · iac/ — Vault objects via the shared module erp's `iac/` declares only its Vault footprint; the Postgres database and `erp_role` themselves come from factory ([postgres-iac](../factory-provisioning/opentofu/postgres-iac.md)). | Element | Value | Source | |---|---|---| | Shared module | `app_roles` from `arcodange-org/tools` (`hashicorp-vault/iac/modules/app_roles`, `ref=main`), `name = "erp"` | [iac/main.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/main.tf) | | What the module provisions | the `postgres/creds/erp` dynamic role + a Kubernetes auth `role erp` + the `kvv2` path prefix | [tools secrets-and-vso](../tools/secrets-and-vso.md) | | Admin password | `random_password.admin_initial_password` (length 32) → `DOLI_ADMIN_PASSWORD` | [iac/main.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/main.tf) | | Instance ID | `random_uuid.dolibarr_id` with `lifecycle { prevent_destroy = true }` → `DOLI_INSTANCE_UNIQUE_ID` (encryption salt + module licensing) | [iac/main.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/main.tf) | | KV secret | `vault_kv_secret_v2` at `config` (i.e. `erp/config`), data = `DOLI_ADMIN_LOGIN` + `DOLI_ADMIN_PASSWORD` + `DOLI_INSTANCE_UNIQUE_ID` | [iac/main.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/main.tf) | | Backend | GCS bucket `arcodange-tf`, prefix `erp/main` | [iac/backend.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/backend.tf) | | Vault provider | `address = https://vault.arcodange.lab`, `auth_login_jwt` `mount = gitea_jwt`, `role = gitea_cicd_erp`, provider `vault` `4.4.0` | [iac/providers.tf](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/iac/providers.tf) | The Postgres dynamic role created here `GRANT`s **`erp_role`** — the stable role created by factory ([postgres-iac](../factory-provisioning/opentofu/postgres-iac.md)) — so every rotated DB user inherits the right schema privileges. This is the same KV secret the chart's `VaultStaticSecret` reads back as `secretkv`, closing the loop between `iac/` (writes config) and `chart/` (consumes it). > [!WARNING] > **The OpenTofu state holds the plaintext admin password.** `random_password.admin_initial_password` is stored unencrypted in the GCS state at `arcodange-tf/erp/main`. Anyone with read access to that state bucket can read `DOLI_ADMIN_PASSWORD`. Treat the `erp/main` state prefix as a secret; do not copy it locally unprotected. The `random_uuid` instance ID is similarly in state but is guarded by `prevent_destroy` because losing it breaks decryption of stored data and invalidates purchased modules. ## 8 · CI — vault.yaml | Element | Value | Source | |---|---|---| | Workflow | `Hashicorp Vault` (`.gitea/workflows/vault.yaml`) | [.gitea/workflows/vault.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/.gitea/workflows/vault.yaml) | | Triggers | `workflow_dispatch`, plus `push` / `pull_request` on `iac/*.tf` | [.gitea/workflows/vault.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/.gitea/workflows/vault.yaml) | | Job 1 | `gitea_vault_auth` — mints a Gitea OIDC JWT for Vault | [.gitea/workflows/vault.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/.gitea/workflows/vault.yaml) | | Job 2 | `tofu` — `dflook/terraform-apply` over `iac/`, `auto_approve: true`, **OpenTofu `1.8.2`** | [.gitea/workflows/vault.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/.gitea/workflows/vault.yaml) | | Secrets | `TERRAFORM_SSH_KEY` (SSH key to clone the `app_roles` module from `tools`) + `HOMELAB_CA_CERT` (Vault self-signed CA) + `GOOGLE_BACKEND_CREDENTIALS` (GCS state) | [.gitea/workflows/vault.yaml](https://gitea.arcodange.lab/arcodange-org/erp/src/branch/main/.gitea/workflows/vault.yaml) | This `tofu apply` follows the lab-wide pattern documented in [factory ci-apply-flow](../factory-provisioning/opentofu/ci-apply-flow.md). There is **no application-image build step** — the chart is delivered by ArgoCD ([01 · factory](../lab-ecosystem/01-factory.md)), and the image is upstream. ## 9 · `` convention mapping erp follows the lab's per-app naming convention — see [naming-conventions](../lab-ecosystem/naming-conventions.md). With ` = erp`: | `` slot | erp value | |---|---| | Repo | `arcodange-org/erp` | | K8s namespace | `erp` | | Internal host | `erp.arcodange.lab` | | ServiceAccount | `erp` | | Vault Kubernetes auth role | `erp` | | Vault KV path | `kvv2` `erp/config` → Secret `secretkv` | | Vault dynamic DB path | `postgres/creds/erp` → Secret `vso-db-credentials` | | Postgres database | `erp` | | Postgres stable role | `erp_role` | | OpenTofu state prefix | GCS `arcodange-tf/erp/main` | | Gitea CI Vault role | `gitea_cicd_erp` | | Document PVC | `erp` (50Gi Longhorn RWX) | ## Cross-references - [ERP hub](README.md) — the orientation map for the whole guidebook. - [Backup & recovery](backup-and-recovery.md) — protecting the 50Gi document PVC and the `erp` database; cluster-recovery ordering (unseal Vault before scaling erp up). - [Operations](operations.md) — day-to-day operational tasks, including running the table-ownership SQL by hand. - [tools secrets-and-vso](../tools/secrets-and-vso.md) — the `app_roles` module, the VSO runtime that materialises `secretkv` + `vso-db-credentials`, and the `pgbouncer.tools` pooler. - [factory postgres-iac](../factory-provisioning/opentofu/postgres-iac.md) — provisions the `erp` database and the stable `erp_role` the dynamic creds inherit. - [factory ci-apply-flow](../factory-provisioning/opentofu/ci-apply-flow.md) — the shared `tofu apply` CI pattern erp's `vault.yaml` follows. - [naming-conventions](../lab-ecosystem/naming-conventions.md) — the `` slots filled in [§9](#9--app-convention-mapping). - [webapp](../applications/webapp.md) — the archetype that *does* build its own image; erp differs by reusing the upstream Dolibarr image.