# KodeMed Deployment Modes — Which One to Use One-page decision guide for operators and partners. All modes deploy the same stack (PostgreSQL, Keycloak or external IdP, Server, DataServer, GrouperServer, **SearchServer**, CodingUI); they differ only in **who terminates TLS, what DNS exists, and who operates the proxy**. > **SearchServer (Lucene thesaurus search, #720) is a base component of the > stack** — not optional. It runs one single-language instance per language > (`-de`, `-fr`, `-it`); DataServer falls back to PostgreSQL `pg_trgm` only for > a language whose instance is unconfigured. How each deployment mode provides it: > - **Docker / Podman** — `setup.sh` + `docker-compose.demo.yml` deploy all three > instances (`kodemed-searchserver`/`-fr`/`-it`); the proxy overlays keep their > ports internal. The **compose file itself is byte-identical under Podman** > (`podman compose`, Podman >= 4.7, or `podman-compose`). Note `setup.sh` is not: > it requires the `docker` CLI and `docker compose` outright, which on > Rocky/RHEL means installing `podman-docker` (the runbooks do, in step 1). > `kodemed-install.sh` needs no shim — it detects the engine itself. > - **Native / systemd** — `install-kodemed.sh` installs the > `kodemed-searchserver-de/-fr/-it` systemd units and wires > `KODEMED_SEARCH_SERVER_{DE,FR,IT}_URL` into the DataServer config. > - **Helm / OpenShift** — one StatefulSet per language in `charts/kodemed/`, > but **`searchserver.enabled` defaults to `false`** (#1873). Set it to `true` > in your values file, or that cluster silently runs on the `pg_trgm` > fallback: search still answers, only worse, with nothing in any log to > say the Lucene instances were never deployed. > - **Browser Portal Deployment Wizard** (`app.js` / `headless-generate.mjs`) — > now emits the three internal-only instances too (#1423), so wizard-generated > preset ZIPs run on Lucene, not the `pg_trgm` fallback. > **Getting a production cert (valid for years, trusted by Opale + all clients):** > see `Documentation/KodeMed_TLS_Certificate_Guide.md` — request it from the > customer's internal CA (AD CS), which every domain machine already trusts, then > deploy in custom-cert mode. Self-signed (IP preset) is for evaluation only. > **Certificate policy: the customer always provides the certificates.** > KodeMed never manages production certificates — every mode just makes > configuring the customer's certs easy (a mount point, or their own proxy). > Caddy's self-signed mode exists only for quick tests; Let's Encrypt only > applies to internet-exposed hosts (our demo/SaaS), never to the typical > hospital-internal network. ## How you actually install it (#1737, #1736) Pick a mode below, generate the package, then run **one command on the server**: ```bash cd /opt/kodemed ./kodemed-install.sh --dry-run # prints the whole plan, changes nothing ./kodemed-install.sh # install (--yes for unattended) ``` `kodemed-install.sh` ships inside every generated ZIP and in the installer bundle. It does the pre-flight (engine, compose, CPU/RAM/disk, free ports, outbound HTTPS to Harbor), logs in to the registry, pulls and unpacks the classification + grouper data, fixes the file permissions that otherwise break licence replacement, starts the stack, waits for the import **before** reindexing, and refuses to call itself done until the thesaurus is non-empty. It is idempotent — if a step fails, fix the cause and run it again. Day 2 is the same shape: `./kodemed-update.sh` (backs up, merges `.env` keeping every existing value, pulls, verifies, and rolls back automatically on failure — never `down`, never `-v`). Both run on **Docker and Podman**; the engine and compose front-end are auto-detected. > The older `setup.sh` flags documented throughout this file still work and are what > the one-command installer calls underneath. Reach for them directly when you need a > mode the presets do not cover; otherwise use the installer. --- ## Which database engine? (#1871) **PostgreSQL is what every installer deploys, and it is the default everywhere.** If you do nothing, you get PostgreSQL — bundled as a container in the Docker and Podman modes, or expected to be already running for the native install. Three choices exist, and they are two independent questions: | | bundled container | **your own server** | |---|---|---| | **PostgreSQL** | the default — `setup.sh`, the wizard presets, `kodemed-install.sh` | supported — point the datasource at your instance | | **SQL Server** | not offered, and never will be | **supported** — `setup.sh --db-engine=mssql` (Docker Compose). Native install and Helm still repoint after the fact — see below | ### SQL Server, stated plainly The *application* runs on Microsoft SQL Server today: KodeMed.Server and KodeMed.DataServer ship an `application-mssql.yml` profile and the JDBC driver, the three SearchServers ship the driver, and the GrouperServer holds no database at all. Our own TEST environment runs the full stack against SQL Server 2025 (17.0.1125.2, Express Edition), verified 2026-10-05. **If the customer supplies the database, hand their DBA [`MSSQL-REQUIREMENTS.md`](MSSQL-REQUIREMENTS.md)** — collation, permissions, the settings that must be right before the database is created, and how to verify the install afterwards. The collation in particular cannot be changed later without recreating the database. The **Docker Compose installer configures it directly**: `setup.sh --db-engine=mssql --db-host= --db-name= --db-user=` builds the JDBC URL with both hard rules, writes the datasource into `.env` and loads `docker-compose.mssql.yml` for the Hibernate dialect. The PostgreSQL rendering is unchanged, byte for byte, for everyone else. What still does **not** offer the choice is the native installer and the Helm chart; those deployments must be repointed after the fact, and that remainder is #1871. Treat SQL Server as *self-service on Docker Compose*, *assisted elsewhere*. **Keycloak keeps its own PostgreSQL regardless.** It is a separate database in a separate container and the engine choice does not touch it — an operator who picks SQL Server will still see a `postgres` container, and that is correct. ### Repointing an existing deployment: the search indexes do not follow The three SearchServers hold their own Lucene index on disk and do **not** rebuild on start (`SEARCHSERVER_REBUILD_ON_STARTUP=false`). Point the DataServer at a different database and they come up **READY**, serving an index built from the *other* engine's data — the Lucene indexes do not follow the datasource. That is worse than being down: searches answer, nobody sees an error, and the results belong to a database the system is no longer using. So a repoint is three steps, not one: 1. rewrite the datasource **and the Hibernate dialect** in every service env file (`SPRING_JPA_DATABASE_PLATFORM` is set explicitly by the installer and an environment variable wins over the `mssql` profile's default — the SearchServers get no profile at all, so that line is the only thing telling them which dialect to use); 2. keep a copy of each index per engine, so a rollback is a directory rename rather than a ~15-minute rebuild per language; 3. set `SEARCHSERVER_REBUILD_ON_STARTUP=true` for **that one start**, then clear it — left on, every routine restart pays the rebuild. `scripts/test/switch-db-engine.sh` does all three. Do not hand-edit the env files. > **If you do repoint a deployment at SQL Server, the JDBC URL MUST carry > `sendStringParametersAsUnicode=false`.** The collation is > `Latin1_General_100_CS_AS_SC_UTF8`, so `varchar` already stores Unicode. Without > that flag every parameter is sent as `nvarchar`, each comparison against a > `varchar` column becomes an implicit conversion, and the indexes silently stop > being used — everything still works, only slower, with nothing in any log to say > why. Let the tooling build the URL rather than typing it. > **And it MUST carry `socketTimeout`.** The driver's default is `0` — wait > forever. Measured on our TEST host: two SearchServer threads sat blocked in a > socket read for 32 minutes while SQL Server had **zero** active requests for a > full minute. The server was not slow, the connections were dead, and nothing > ever told the client; the same query completes in about four seconds. Without > this the service never becomes ready and no log says why. The tooling sets > `socketTimeout=60000`, far above any legitimate single read and far below > "never". --- ## Decision tree (typical case: hospital-internal network) 1. **Just trying it on one machine?** → **Localhost** (portal preset). No TLS, no DNS, 5 minutes. 2. **Only an internal IP, no DNS, no own proxy?** → **IP + HTTPS** (portal preset / `setup.sh --ip=`). Bundled Caddy, self-signed — browser warning accepted once. For evaluation, not production. 3. **ONE internal DNS name + the customer runs their own nginx/proxy?** → ⭐ **Single domain + your nginx** (portal preset / `setup.sh --single-domain --domain=`). One DNS record, one plain cert from the customer's CA, TLS fully in their hands. **Recommended default for hospitals with their own IT.** Guide: `installer/server/docker/EXTERNAL-PROXY.md`. 4. **Internal subdomains possible + own proxy?** → **Subdomains behind your proxy** (`setup.sh --external-proxy --domain=`). One hostname per service (cleaner URLs, local docs portal included); needs a wildcard or multi-SAN cert from the customer's CA. 5. **DNS name but NO own proxy?** → **Hostname** via the **Portal wizard preset with TLS mode = custom cert**: bundled Caddy terminates TLS with the customer's certificate (dropped into `certs/`), port-based routing (`:8443` …). Only pick TLS mode "auto" (Let's Encrypt) when the host is reachable from the internet. > ⚠ **Not `setup.sh --domain=`.** That flag has no custom-cert mode (#1873 > B3): it copies `Caddyfile.domain`, which is **subdomain** mode on `:443` > with **Let's Encrypt**. On an internal hospital name ACME can never > validate, so HTTPS simply never comes up and the only symptom is a > certificate-retry loop in the Caddy log. `setup.sh`'s only two TLS > outcomes are Let's Encrypt (domain mode) and self-signed (IP mode). > > If you must use `setup.sh` with your own certificate: generate in domain > mode, then put `cert.pem`/`key.pem` in `certs/` and uncomment the `tls` > line in every site block of the generated `Caddyfile` — the mount is > already there and already points where those lines now read. 6. **Corporate IdP (ADFS / Azure AD / own Keycloak)?** → any mode above + `--no-keycloak --oidc-issuer=` (portal: SaaS preset for the multi-host external-IdP shape). 7. **HIS integration (DLL / REST) on-premise?** → **HIS REST** portal preset — pre-wires the HIS issuer and integration profile. 8. **OpenShift / Kubernetes cluster?** → **Helm chart** (`charts/kodemed/`, see `Documentation/policies/OPENSHIFT_DEPLOYMENT_POLICY.md`). 9. **Docker not allowed?** → **Native install** (`installer/server/linux/install-kodemed.sh`, systemd + JARs — same topology our TEST runs). **Podman instead of Docker?** → the same `docker-compose.demo.yml` + `setup.sh` run unchanged under `podman compose` (Podman >= 4.7) or `podman-compose`; SearchServer and all other services are identical. **Rule of thumb:** the deciding question is *who terminates TLS*. Customer has a proxy team → modes 3/4 (certs never leave their proxy). No proxy → bundled Caddy with the customer's cert (5), self-signed only to evaluate (2). ## Mode matrix | Mode | Get it via | TLS / certs | DNS needed | SSO | Verified by | |---|---|---|---|---|---| | Localhost | Portal wizard preset | none (http) | none | bundled KC | preset tests + compose parse | | IP + HTTPS | Portal preset / `setup.sh --ip=` | bundled Caddy, self-signed (test only) | none | bundled KC | preset tests; wizard-e2e harness | | Hostname (+ customer cert) | **Portal preset, TLS = custom** (this mode only) | bundled Caddy + **customer cert** in `certs/` | 1 record | bundled KC | preset tests + compose parse | | Hostname (Let's Encrypt) | `setup.sh --domain=` | bundled Caddy, **Let's Encrypt** — internet-reachable hosts only | **nine** subdomain records (or a wildcard) | bundled KC | setup.sh dry-run + compose parse | | Subdomains + customer proxy | `setup.sh --external-proxy --domain=` (#1205) | **customer proxy** (wildcard/multi-SAN cert) | subdomains or wildcard | bundled KC or external | dry-run + compose-validity suite | | ⭐ Single domain + customer nginx | Portal preset / `setup.sh --single-domain` (#1206) | **customer nginx** (single-name cert) | 1 record | bundled KC under `/auth` or external | full E2E (Hetzner, nginx 1.24) — both generators | | SaaS multi-host | Portal preset | Caddy + Let's Encrypt (internet-exposed only) | 5 subdomains | external IdP | preset tests; our own demo/CH run this shape | | HIS REST on-prem | Portal preset | per chosen base mode | per base mode | HIS IdP | preset tests + compose parse | | Helm / OpenShift | `charts/kodemed/` | cluster ingress (customer certs) | cluster DNS | external IdP typical | chart CI / client clusters | | Native (systemd) | `install-kodemed.sh` | front proxy of choice | per setup | either | our TEST environment (CI daily) | | Podman (Rocky/RHEL) | `podman compose` / `podman-compose` — **manual runbook** `installer/server/docker/PODMAN-ROCKY9.md` | same as the Docker mode chosen | per setup | either | same compose as Docker (#1418 covers wizard-e2e on Rocky/Podman) | All portal ZIPs are generated **in the partner's browser** (portal → Deployment Wizard → preset → Download ZIP); `headless-generate.mjs` produces byte-identical packages from the command line for testing. > **Running the Wizard ZIP under Podman on Rocky Linux 9 / RHEL?** The Wizard > `README.md` targets Docker/Ubuntu. Follow > **`installer/server/docker/PODMAN-ROCKY9.md`** for the engine-specific deltas > (podman install, firewalld ports, `podman login`, SELinux relabel of the > bind mounts, `podman compose up`). Same compose, same images — only the host > prep differs. **SearchServer status per mode:** the instances are internal-only everywhere — no host port, no proxy route. `docker-compose.demo.yml` declares **no `ports:` mapping at all** for `kodemed-searchserver`/`-fr`/`-it`, so even running it bare with no proxy overlay publishes nothing (the older warning in this file about `0.0.0.0:8083`/`8084` described a file that has since changed; #1153 for Caddy and #1205 for external-proxy closed it). Every generator deploys the full `-de`/`-fr`/`-it` set: `setup.sh` (Docker/Podman), `install-kodemed.sh` (native), and the browser Portal wizard (#1423) — **except Helm, where `searchserver.enabled` is `false` until you set it.**