This is an old revision of the document!
# Keycloak + Postgres — Rootless Podman Quadlet
Rootless Podman as user `worker` on host `support`. Apache + Let's Encrypt run as `root`. Keycloak is bound to localhost only.
| Role | Bind | Public |
| — | — | — |
| Keycloak | `127.0.0.1:8080` → container `8080` | `https://auth.haacksnetworking.org` |
| Postgres | internal only | none |
Images:
- `docker.io/library/postgres:16-alpine` - `quay.io/keycloak/keycloak:26.7`
Network: `keycloak_default` Data: `~/keycloak/postgres-data` Do not use `podman generate systemd`. Units come from Quadlet files in `~/.config/containers/systemd/`.
—
## 1. Directory (worker)
```bash mkdir -p ~/keycloak/postgres-data ```
—
## 2. Network (worker)
```bash podman network exists keycloak_default || podman network create keycloak_default ```
—
## 3. Secrets
Placeholders used below:
``` POSTGRES_PASSWORD=replace-with-your-db-password KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-your-bootstrap-admin-password ```
Bootstrap admin credentials apply only on an empty database. After the first start, change the admin password in the GUI. Do not rely on the env vars after that.
—
## 4. Quadlets
File: `~/.config/containers/systemd/container-keycloak-postgres.container`
```bash systemctl –user disable –now container-keycloak.service container-keycloak-postgres.service 2>/dev/null || true rm -f ~/.config/systemd/user/container-keycloak.service \
~/.config/systemd/user/container-keycloak-postgres.service
podman rm -f keycloak keycloak-postgres
mkdir -p ~/.config/containers/systemd
cat > ~/.config/containers/systemd/container-keycloak-postgres.container « 'EOF' [Container] ContainerName=keycloak-postgres Image=docker.io/library/postgres:16-alpine Network=keycloak_default Volume=/home/worker/keycloak/postgres-data:/var/lib/postgresql/data:Z Environment=POSTGRES_DB=keycloak Environment=POSTGRES_USER=keycloak Environment=POSTGRES_PASSWORD=replace-with-your-db-password PodmanArgs=–cpus=2 –memory=4g [Service] Restart=always TimeoutStopSec=120 [Install] WantedBy=default.target EOF
cat > ~/.config/containers/systemd/container-keycloak.container « 'EOF' [Unit] After=container-keycloak-postgres.service Wants=container-keycloak-postgres.service [Container] ContainerName=keycloak Image=quay.io/keycloak/keycloak:26.7 Exec=start Network=keycloak_default PublishPort=127.0.0.1:8080:8080 Environment=KC_DB=postgres Environment=KC_DB_URL=jdbc:postgresql://keycloak-postgres:5432/keycloak Environment=KC_DB_USERNAME=keycloak Environment=KC_DB_PASSWORD=replace-with-your-db-password Environment=KC_HOSTNAME=https://auth.haacksnetworking.org Environment=KC_HEALTH_ENABLED=true Environment=KC_HTTP_ENABLED=true Environment=KC_PROXY_HEADERS=xforwarded PodmanArgs=–cpus=2 –memory=6g [Service] Restart=always TimeoutStopSec=120 [Install] WantedBy=default.target EOF
systemctl –user daemon-reload systemctl –user reset-failed container-keycloak-postgres.service container-keycloak.service systemctl –user start container-keycloak-postgres.service systemctl –user start container-keycloak.service ```
`WantedBy=default.target` starts them. Do not `systemctl enable` a Quadlet unit. Do not `podman generate systemd`.
Keycloak depends on Postgres via the `[Unit]` After/Wants lines.
—
## 5. Verify
```bash systemctl –user is-active container-keycloak-postgres.service container-keycloak.service podman inspect keycloak-postgres keycloak –format 'name cpus=hostconfig.nanocpus memory=hostconfig.memory' podman exec keycloak-postgres pg_isready -U keycloak curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health/ready || curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ ```
Expect both `active`. Health may take a minute on first start while Keycloak builds. On 26.x the management health endpoint can be on port 9000 inside the container. If `curl http://127.0.0.1:8080/` returns the welcome page, the proxy path is fine.
—
## 6. Upgrade script
`/usr/local/bin/upgrade-keycloak.sh`. A tag change is an edit to `Image=` in the matching `.container` file before `daemon-reload`. The script does not recreate the units.
```bash #!/bin/bash set -euo pipefail
podman pull docker.io/library/postgres:16-alpine podman pull quay.io/keycloak/keycloak:26.7
podman network exists keycloak_default || podman network create keycloak_default
systemctl –user stop container-keycloak.service systemctl –user stop container-keycloak-postgres.service
systemctl –user daemon-reload systemctl –user reset-failed container-keycloak-postgres.service container-keycloak.service systemctl –user start container-keycloak-postgres.service systemctl –user start container-keycloak.service ```
Run it as `worker`:
```bash su - worker -c '/bin/bash /usr/local/bin/upgrade-keycloak.sh' ```
Do not `sudo -u worker`. That drops the session bus.
—
## 7. Apache reverse proxy (root)
```bash a2enmod proxy proxy_http proxy_wstunnel headers rewrite ssl ```
`/etc/apache2/sites-available/auth.haacksnetworking.org.conf`:
```apache <VirtualHost *:80>
ServerName auth.haacksnetworking.org
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
<VirtualHost *:443>
ServerName auth.haacksnetworking.org
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/auth.haacksnetworking.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/auth.haacksnetworking.org/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
ProxyPass / http://127.0.0.1:8080/ upgrade=websocket
ProxyPassReverse / http://127.0.0.1:8080/
ProxyTimeout 60
LimitRequestFieldSize 65535
ErrorLog ${APACHE_LOG_DIR}/auth.haacksnetworking.org-error.log
CustomLog ${APACHE_LOG_DIR}/auth.haacksnetworking.org-access.log combined
</VirtualHost> ```
```bash a2ensite auth.haacksnetworking.org.conf apache2ctl configtest && systemctl reload apache2 curl -sI https://auth.haacksnetworking.org/ ```
`KC_PROXY_HEADERS=xforwarded` matches the `X-Forwarded-*` headers. `KC_HOSTNAME` must be `https://auth.haacksnetworking.org`.
—
## 8. First login
1. Open `https://auth.haacksnetworking.org` 2. Administration Console 3. User: `admin`
Password: the bootstrap value (first empty volume only)
4. Admin → admin user → Credentials — set a new password, Temporary off 5. Realm settings → General
Do not keep the bootstrap password.
—
## 9. Incus OIDC
Stay out of `master` for apps. Create a realm.
Realm
- Name: `incus` - Enabled: on
Issuer (must match Incus exactly):
`https://auth.haacksnetworking.org/realms/incus`
```bash curl -sI https://auth.haacksnetworking.org/realms/incus/.well-known/openid-configuration ```
Must be 200. The `issuer` value in that JSON is what Incus uses.
Users (in realm `incus`)
- Username for SSO - Email filled in if `oidc.claim=email` - Email verified: on - Credentials → password, Temporary off
Create one user per human. Do not log into Incus as Keycloak `admin` unless that user also exists in the `incus` realm.
Client
- Clients → Create client - Type: OpenID Connect - Client ID: `incus` - Client authentication: Off (public / PKCE) - Standard flow: On - Valid redirect URIs: `https:<incus-ui-host>/oidc/callback` Example: `https://support.haacksnetworking.org:8443/oidc/callback` - Web origins: `https:<incus-ui-host>` - Save
No client secret. Incus does not send one.
Incus
```bash incus config set oidc.issuer=https://auth.haacksnetworking.org/realms/incus/ incus config set oidc.client.id=incus incus config set oidc.scopes=openid,email,profile incus config set oidc.claim=preferred_username ```
`oidc.claim` can be `email` instead.
```bash incus config show | grep oidc ```
Open the Incus UI → Login with SSO → Keycloak `incus` realm user.
Then grant that identity. SSO alone is not admin:
```bash incus auth identity list ```
From the unix socket as root, grant Admin (or project access) to the new `oidc/` identity. Until you do, the UI logs in and then shows nothing / 403.
—
## Facts
-