# KodeMed on Rocky Linux 9 with Podman — Install Runbook Install the KodeMed Deployment-Wizard package (the ZIP the portal generates) on **Rocky Linux 9** with **Podman**. Written to be copy-paste simple: run the six steps as **root** and you are done. No SELinux tweaking, no compose editing — the wizard ZIP is already Podman-safe. > **Validated end-to-end on Rocky Linux 9.8 + Podman 5.8.2** (podman-compose): full > stack up, classification data imported, browser login working. **ZIPs generated > from Aug 2026 onward** carry the Podman fixes (fully-qualified images, SELinux > `:z` on the mounts, Podman-safe tmpfs, concrete Keycloak CORS origins) — so the > old manual SELinux / short-name / tmpfs steps are **no longer needed**. If you > are on an **older ZIP**, see the *Older ZIPs* appendix at the end. > **Run everything as `root`** (rootful Podman): ports 443/8443/… are < 1024, and > — important — the user that runs `podman login` / `oras login` must be the **same > user that runs the pull and the compose**. Log in as root, pull as root. (Mixing > "login as your user, pull as root" is the #1 cause of a silent `unauthorized`.) --- ## Before you start - A Rocky Linux 9 host with **root** access. - **At least 8 GB RAM** (the stack is ~11 mostly-JVM containers). 4 GB is not enough. - **At least ~30 GB free disk.** Podman storage (`/var/lib/containers`) holds the images + DB/Lucene-index volumes (~20 GB), and `/opt/kodemed` holds the deployment files + `import-data` (~10 GB). On hosts with small partitioned LVs (e.g. a 3 GB `/var`), point **both** the Podman storage (`graphroot` in `/etc/containers/storage.conf`) and `/opt/kodemed` at a larger volume before deploying, or the pull/import fails with `no space left on device`. - **Harbor credentials** from KodeMed AG: a robot account (`robot$…`) + its secret. - The **Wizard ZIP** for your host. For a single server reached by IP with no DNS, pick the **IP + self-signed (ip-caddy)** preset in the portal. Copy the ZIP to the server, e.g. `scp deployment.zip root@:/opt/`. --- ## 1. Install Podman + tools (once per host) ```bash # EPEL provides podman-compose. On Rocky it's a one-liner (extras repo). dnf install -y epel-release dnf install -y podman podman-docker podman-compose openssl unzip curl # ORAS CLI (pulls the classification-data + grouper artifacts in step 4) curl -fsSL "https://github.com/oras-project/oras/releases/download/v1.2.0/oras_1.2.0_linux_amd64.tar.gz" \ | tar -xz -C /usr/local/bin oras # Symlink into /usr/bin so `oras` is on PATH even on hardened hosts whose PATH omits /usr/local/bin ln -sf /usr/local/bin/oras /usr/bin/oras podman --version && podman compose version && oras version ``` > **Air-gapped / proxy?** `epel-release` + `dnf install` need repo access. On an > isolated host, point `dnf` at your internal mirror (or preload `podman-compose` > from it); everything else in this runbook is offline once the images are pulled. ## 2. Open the ports (only if the host runs a local firewall) ```bash # Cloud VMs usually gate at the provider's security group — open the ports THERE. # If firewalld is running locally, open them here (ip-caddy preset ports): systemctl is-active firewalld && { for p in 443 8443 8444 8445 9443; do firewall-cmd --permanent --add-port=${p}/tcp; done firewall-cmd --reload } ``` ## 3. Unpack + log in to Harbor (as root) ```bash mkdir -p /opt/kodemed && cd /opt/kodemed unzip -o /opt/deployment.zip chmod -R a+rX . # make the mounted files readable (safe on any umask) # Robot usernames contain '$' → SINGLE quotes. Log in as root (same user that pulls). podman login harbor.mieresit.com -u 'robot$YOUR_ROBOT' oras login harbor.mieresit.com -u 'robot$YOUR_ROBOT' # oras has its own login ``` ## 4. Download the classification data + grouper specs ```bash cd /opt/kodemed # Classification data → import-data/2026/ oras pull harbor.mieresit.com/kodemed/data:2026 -o import-data/ cd import-data && mkdir -p 2026 unzip -o *.zip -d 2026/ 2>/dev/null || tar -xzf *.tar.gz -C 2026/ rm -f *.zip *.tar.gz && cd .. # Grouper specs (.sgs) + catalogues (.csv) oras pull harbor.mieresit.com/kodemed/grouper:2026 -o ./ unzip -o kodemed-grouper-*.zip 2>/dev/null || tar -xzf kodemed-grouper-*.tar.gz rm -f kodemed-grouper-*.zip kodemed-grouper-*.tar.gz # The DataServer imports as UID 1001 and moves files during the scan → give it ownership: chown -R 1001:1001 import-data/ ``` ## 5. Start the stack ```bash cd /opt/kodemed podman compose up -d podman compose logs -f kodemed-dataserver # watch the first-time import (~3-5 min) ``` If one container is left in state `Created` (an occasional podman-compose race, most often the DataServer), just run `podman compose up -d` again, or start it by name: `podman start kodemed-dataserver`. ## 6. Verify ```bash podman ps --format '{{.Names}} {{.Status}}' # all healthy curl -ks https://:8443/actuator/health # Server → UP curl -ks https://:8444/actuator/health # DataServer → UP curl -ks https://:8445/api/v1/grouper/versions # loaded:true ``` Open the CodingUI at `https:///` (accept the self-signed warning once per port), log in with a bundled demo user (`demo-admin` / `KodeMed2026!`). Ports: **443** UI · **8443** Server (+WebSocket) · **8444** DataServer · **8445** Grouper · **9443** Keycloak. --- ## Troubleshooting **`oras pull` says `unauthorized to access repository … action: pull`, yet the robot has pull rights.** Almost always: you ran `oras login` as one user but the pull runs as another (e.g. logged in as your account, then `su` to root). `oras` reads the credential from the *current* user's store, so it goes anonymous. Fix: run **both** `oras login` and `oras pull` as the **same user** (root). The robot name has a `kodemed+` prefix for project robots — copy it exactly from Harbor. **CodingUI shows a blank/`runtime-config.js` MIME error, or config falls back to localhost.** The UI container can't read the mounted `runtime-config.js`. On a current ZIP this is just file permissions — re-run `chmod -R a+rX /opt/kodemed` then `podman compose up -d --force-recreate kodemed-ui`. **CodingClient / DLL: `Unexpected character <` on `/api/v1/config`, or WebSocket `200 vs 101`.** The `serverUrl` points at the UI (`:443`) instead of the API. Use the **API port**: `https://:8443` (Server + WebSocket), `:8444`/`:8445` for DataServer/Grouper, `:9443` for Keycloak. **Login shows `invalid_grant` / "CORS Missing Allow Origin".** Current ZIPs bake the correct Keycloak CORS origins into the realm, so a **fresh** deploy is fine. If it happens on a **reused** deploy, the Keycloak DB volume is stale — recreate it: `podman compose down && podman volume rm _kodemed-keycloak-db && podman compose up -d`. **A SearchServer stays `starting`/`unhealthy`.** Usually not enough RAM (see the 8 GB prerequisite); they also stay `starting` until the import finishes and they build their Lucene index. --- ## Appendix — older ZIPs (generated before Aug 2026) Only if your ZIP predates the Podman fixes and you hit these errors: - **`short-name resolution enforced but cannot prompt without a TTY`** (Postgres/ Keycloak/Caddy fail): the compose used short image names. Fix once: `sed -i 's/^short-name-mode = "enforcing"/short-name-mode = "permissive"/' /etc/containers/registries.conf` - **`unknown mount option "uid=1001"`** (DataServer never created): the tmpfs used `uid=`. Fix: `sed -i 's/,uid=1001,gid=1001,mode=755/,mode=1777/g' /opt/kodemed/docker-compose.yml` - **Container denied reading a mount on SELinux Enforcing** (`getenforce` = Enforcing): the bind mounts lacked `:z`. Relabel once: `chcon -Rt container_file_t /opt/kodemed/{runtime-config.js,kodemed.license,Caddyfile,specs,catalogues,import-data,kodemed-realm.json,keycloak-init.sh}` Then `podman compose up -d --force-recreate`. The simplest fix, though, is to **regenerate the ZIP from the portal** — the current one needs none of this.