This is an old revision of the document!
——————————————- Keycloak + Postgres on rootless Podman
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-alpinequay.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/.
mkdir -p ~/keycloak/postgres-data
podman network exists keycloak_default || podman network create keycloak_default
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.
File: ~/.config/containers/systemd/container-keycloak-postgres.container
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.
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.
/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.
#!/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:
su - worker -c '/bin/bash /usr/local/bin/upgrade-keycloak.sh'
Do not sudo -u worker. That drops the session bus.
a2enmod proxy proxy_http proxy_wstunnel headers rewrite ssl
/etc/apache2/sites-available/auth.haacksnetworking.org.conf:
<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>
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.
adminhttps://auth.haacksnetworking.orgDo not keep the bootstrap password.
Stay out of master for apps. Create a realm.
Realm
incusIssuer (must match Incus exactly):
https://auth.haacksnetworking.org/realms/incus
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)
oidc.claim=email
Create one user per human. Do not log into Incus as Keycloak admin unless that user also exists in the incus realm.
Client
incushttps://<incus-ui-host>/oidc/callback
Example: https://support.haacksnetworking.org:8443/oidc/callback
https://<incus-ui-host>No client secret. Incus does not send one.
Incus
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.
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:
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.
— oemb1905 2026/10/10 01:12