Keycloak + OAuth2‑Proxy for Ollama (Arch/Alpine, IPv6‑only)

Keycloak + OAuth2‑Proxy for Ollama (Arch/Alpine, IPv6‑only)

Tech-Scroll 108, The Gatekeeper’s Parable

*A city lay in the open plain, filled with treasures of wisdom and skill. Yet its gates were unbarred, and strangers could enter without name or oath.

One day, the people set a watchman at the gate, strong and discerning, who knew each citizen by face and gave passage only to the trusted.

From that day, the treasures within were safe, and the people laboured without fear. The wise knew: it is better to guard the gates before the enemy comes, than to rebuild the walls after they have fallen.*

Introduction

This article exists to demonstrate how to place OAuth2 authentication in front of services such as HomeAssistant and Ollama—two examples of tools that often run locally without built‑in access control. Ollama, for instance, normally listens on localhost seen here as [::1] and accept requests without any authentication, making it a prime candidate for a security layer.

Rather than relying on third‑party identity providers like Google or GitHub, we’ll build our own Keycloak instance and host it ourselves. This costs nothing beyond the hardware or virtual machine it runs on—whether that’s under KVM/Libvirt, a basic VirtualBox install, or another setup entirely. Keycloak will serve as our OAuth2 provider, giving us full control over user management and access policies.

The guide is designed as a practical, repeatable build that starts from bare VMs, configures AAAA‑only access, places Keycloak behind NGINX with end‑to‑end TLS, connects it to PostgreSQL, and secures Ollama’s API using oauth2‑proxy. Along the way, we’ll intentionally break and repair components to show how recovery works.

All configuration examples are in real JSON (with some names changed to protect privacy) so readers can copy‑paste with minimal changes. Once complete, you’ll have OAuth2 in front of your chosen services, and the same approach can be extended to Apache with mod_openidc_connect and countless other applications.

A practical, repeatable build that starts from bare VMs, enables AAAA‑only access, serves Keycloak behind NGINX with end‑to‑end TLS, wires PostgreSQL, and protects an Ollama API using oauth2‑proxy. It also includes intentional break/fix flows and reset guidance.

Platforms: Arch Linux (systemd) and Alpine Linux (OpenRC).
Editors: vim.
Package managers: Arch: pacman / Alpine: apk.
No APT here.
Network stance:
IPv6‑only public edge, no inbound IPv4.

0) Topology & goals

  • idp.breathtechnology.co.ukKeycloak (Arch VM).
    • Keycloak listens TLS on [::1]:8443.
    • NGINX does TCP stream passthrough on [::]:443 to Keycloak.
  • ollama.breathtechnology.co.ukhomer (Arch host) runs oauth2‑proxy in front of Ollama [::]:11434.
  • DNS: AAAA record only for public v6; no A records.
  • TLS: Let’s Encrypt via certbot on each host.
  • DB: PostgreSQL local to Keycloak host.
  • Firewall: nftables; only v6:443 exposed at edge.

1) DNS (AAAA‑only)

Create AAAA records:

idp.breathtechnology.co.uk.      AAAA  2a02:8012:bc57:cafe::babe
ollama.breathtechnology.co.uk.   AAAA  2a02:8012:bc57:c0d2::babe

Ensure the host’s system hostname is not the public FQDN, to avoid nss-myhostname leaking local addresses. Example on Arch:

hostnamectl set-hostname idp-arch
vim /etc/hosts
# keep only
127.0.0.1   localhost
::1         localhost
2a02:8012:bc57:cafe::babe  idp.breathtechnology.co.uk idp

Prefer IPv6 first (glibc):

vim /etc/gai.conf
# add
precedence ::/0  100
precedence ::ffff:0:0/96  10

Disable privacy addresses & RA if using static IPv6:

# Arch & Alpine
tee /etc/sysctl.d/99-ipv6.conf >/dev/null <<'EOF'
net.ipv6.conf.all.use_tempaddr=0
net.ipv6.conf.default.use_tempaddr=0
net.ipv6.conf.all.accept_ra=0
net.ipv6.conf.default.accept_ra=0
EOF
sysctl --system

2) TLS certificates (Let’s Encrypt)

On idp and homer, obtain certificates:

Arch

pacman -Syu --noconfirm certbot
certbot certonly --standalone -d idp.breathtechnology.co.uk

Alpine

apk add certbot
certbot certonly --standalone -d idp.breathtechnology.co.uk

Certificates live under /etc/letsencrypt/live/<host>/.


3) PostgreSQL (Keycloak database)

Arch

pacman -Syu --noconfirm postgresql
sudo -iu postgres initdb -D /var/lib/postgres/data
systemctl enable --now postgresql

# Secure: SCRAM & loopback only
vim /var/lib/postgres/data/postgresql.conf
# set
listen_addresses = '127.0.0.1'
password_encryption = 'scram-sha-256'

vim /var/lib/postgres/data/pg_hba.conf

# replace rules with
local   all   all                        scram-sha-256
host    all   all   127.0.0.1/32         scram-sha-256
# (uncomment ::1 if needed)
# host all all ::1/128                   scram-sha-256
systemctl restart postgresql

# Create DB & user
sudo -iu postgres psql -c "CREATE USER keycloak WITH PASSWORD 'CHANGEME_DB';"

sudo -iu postgres psql -c "CREATE DATABASE keycloak OWNER keycloak;"

psql -h 127.0.0.1 -U keycloak -W -d keycloak -c 'select 1;'
# the result below
Password:
?column?
----------
1
(1 row)

Alpine

# --- Install & initialize (Alpine) ---
apk add postgresql16 postgresql16-openrc
rc-service postgresql initdb
rc-update add postgresql default

# --- Postgres settings: bind to IPv4 + IPv6 loopback, use SCRAM ---
# /var/lib/postgresql/16/data/postgresql.conf
sed -i \
  -e "s/^#\?listen_addresses.*/listen_addresses = '127.0.0.1,::1'/" \
  -e "s/^#\?password_encryption.*/password_encryption = 'scram-sha-256'/" \
  /var/lib/postgresql/16/data/postgresql.conf

# /var/lib/postgresql/16/data/pg_hba.conf  (replace/append these lines)
cat >/var/lib/postgresql/16/data/pg_hba.conf <<'HBA'
# Unix socket (local) connections
local   all             all                                     scram-sha-256
# IPv4 loopback
host    all             all             127.0.0.1/32            scram-sha-256
# IPv6 loopback
host    all             all             ::1/128                 scram-sha-256
HBA

# (optional, but nice to ensure ::1 resolves as localhost/localhost6)
grep -q '^::1' /etc/hosts || echo '::1   localhost localhost6' >>/etc/hosts

# Start / restart
rc-service postgresql start || rc-service postgresql restart

# --- DB & user (SCRAM) ---
sudo -iu postgres psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE ROLE keycloak WITH LOGIN PASSWORD 'CHANGEME_DB';
CREATE DATABASE keycloak OWNER keycloak;
SQL

# --- Smoke tests: socket, IPv4, IPv6 ---
# Socket (no -h): will hit the 'local' rule
psql -U keycloak -d keycloak -c 'select 1;'

# IPv4 loopback
psql -h 127.0.0.1 -U keycloak -W -d keycloak -c 'select 1;'

# IPv6 loopback
psql -h ::1 -U keycloak -W -d keycloak -c 'select 1;'

4) Install Keycloak

Arch

pacman -Syu --noconfirm keycloak nginx

Alpine

apk add keycloak nginx
rc-update add keycloak default

5) Keycloak TLS on loopback + NGINX stream on 443

Copy LE certs into a Keycloak‑owned directory (both Arch/Alpine):

install -d -m 0750 -o keycloak -g keycloak /etc/keycloak/certs
install -m 0640 -o keycloak -g keycloak \
  /etc/letsencrypt/live/idp.breathtechnology.co.uk/fullchain.pem \
  /etc/keycloak/certs/fullchain.pem
install -m 0600 -o keycloak -g keycloak \
  /etc/letsencrypt/live/idp.breathtechnology.co.uk/privkey.pem \
  /etc/keycloak/certs/privkey.pem

/etc/keycloak/keycloak.conf

# Public hostnames
hostname=idp.breathtechnology.co.uk
hostname-url=https://idp.breathtechnology.co.uk
hostname-admin-url=https://idp.breathtechnology.co.uk
hostname-port=443
hostname-strict=true
hostname-strict-https=true
proxy=edge

# HTTPS only on loopback
http-enabled=false
https-host=[::1]
https-port=8443
https-certificate-file=/etc/keycloak/certs/fullchain.pem
https-certificate-key-file=/etc/keycloak/certs/privkey.pem
https-client-auth=none

# Database
db=postgres
db-url=jdbc:postgresql://[::1]:5432/keycloak
db-username=keycloak
db-password=CHANGEME_DB
cache=local

Start and confirm the listener:

# Arch
systemctl enable --now keycloak
journalctl -u keycloak -n 80 --no-pager
ss -ltnp | grep 8443    # expect [::1]:8443 or 127.0.0.1:8443

# Alpine
rc-service keycloak start
rc-service keycloak status
netstat -ntulp | grep 8443

NGINX: TCP stream passthrough on 443 (IPv6‑only)

Top‑level include in /etc/nginx/nginx.conf (outside http {}):

include /etc/nginx/stream.conf;

Create /etc/nginx/stream.conf:

stream {
  server {
    listen [::]:443 ipv6only=on;
    proxy_connect_timeout 10s;
    proxy_timeout 300s;
    proxy_pass [::1]:8443;
  }
}

HTTP→HTTPS redirect (/etc/nginx/http.d/idp-redirect.conf):

server {
  listen [::]:80 ipv6only=on;
  server_name idp.breathtechnology.co.uk;
  return 301 https://$host$request_uri;
}

Reload:

nginx -t && (systemctl restart nginx || rc-service nginx restart)

Renewal hook to sync certs into Keycloak dir

install -d /etc/letsencrypt/renewal-hooks/deploy
cat >/etc/letsencrypt/renewal-hooks/deploy/20-keycloak-copy-restart.sh <<'EOF'
#!/bin/sh
set -e
LINEAGE="$RENEWED_LINEAGE"
install -m 0640 -o keycloak -g keycloak "$LINEAGE/fullchain.pem" /etc/keycloak/certs/fullchain.pem
install -m 0600 -o keycloak -g keycloak "$LINEAGE/privkey.pem"   /etc/keycloak/certs/privkey.pem

# Arch
if command -v systemctl >/dev/null; then systemctl restart keycloak; fi

# Alpine
if command -v rc-service >/dev/null; then rc-service keycloak restart; fi
EOF

# Both
chmod +x /etc/letsencrypt/renewal-hooks/deploy/20-keycloak-copy-restart.sh

6) Bootstrap admin safely, then remove env

One‑time bootstrap via systemd/OpenRC only to create the admin, then delete the secrets.

Arch (systemd)

systemctl edit keycloak

Add:

[Service]
Environment=KEYCLOAK_ADMIN=director
Environment=KEYCLOAK_ADMIN_PASSWORD=CHANGEME_ADMIN

Apply:

systemctl daemon-reload
systemctl restart keycloak

Login with CLI and verify internal clients list includes realm-management:

kcadm.sh config credentials --server https://idp.breathtechnology.co.uk --realm master \
  --user director --password 'CHANGEME_ADMIN'

kcadm.sh get clients -r master --fields clientId | jq -r '.[].clientId' | grep -x realm-management

Remove the env from the override and restart (admin user persists inside KC):

vim /etc/systemd/system/keycloak.service.d/override.conf
# delete the two Environment= lines
systemctl daemon-reload
systemctl restart keycloak

Alpine (OpenRC)

If using the packaged service wrapper, export once and start; then unset and restart. Or set in /etc/conf.d/keycloak, start, then remove.


7) Create a realm for the domain (JSON import)

Create /home/director/breath-realm.json:
Below you see an actual tested working JSON Realm file.

{
  "realm": "breath",
  "enabled": true,
  "displayName": "Breath Technology",
  "sslRequired": "all",
  "attributes": {
    "frontendUrl": "https://idp.breathtechnology.co.uk/realms/breath",
    "adminTheme": "keycloak.v2",
    "loginTheme": "keycloak.v2"
  },
  "internationalizationEnabled": true,
  "supportedLocales": ["en"],
  "defaultLocale": "en",
  "bruteForceProtected": true,
  "failureFactor": 5,
  "maxFailureWaitSeconds": 900,
  "waitIncrementSeconds": 60,
  "loginWithEmailAllowed": true,
  "registrationAllowed": false,
  "verifyEmail": false,
  "accessTokenLifespan": 1800,
  "ssoSessionIdleTimeout": 36000,
  "ssoSessionMaxLifespan": 86400,
  "clients": [
    {
      "clientId": "director",
      "protocol": "openid-connect",
      "publicClient": false,
      "serviceAccountsEnabled": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "rootUrl": "https://homer.breathtechnology.co.uk",
      "redirectUris": ["https://homer.breathtechnology.co.uk/*"],
      "webOrigins": ["https://homer.breathtechnology.co.uk"],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "+"
      }
    },
    {
      "clientId": "ollama-gw",
      "protocol": "openid-connect",
      "publicClient": false,
      "serviceAccountsEnabled": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "rootUrl": "https://ollama.breathtechnology.co.uk",
      "redirectUris": [
        "https://ollama.breathtechnology.co.uk/*",
        "https://*.breathtechnology.co.uk/oauth2/callback"
      ],
      "webOrigins": ["https://ollama.breathtechnology.co.uk"],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "+"
      }
    },
    {
      "clientId": "ha-bridge",
      "protocol": "openid-connect",
      "publicClient": true,
      "standardFlowEnabled": true,
      "implicitFlowEnabled": false,
      "directAccessGrantsEnabled": false,
      "rootUrl": "https://ha-bridge.breathtechnology.co.uk",
      "redirectUris": ["https://ha-bridge.breathtechnology.co.uk/*"],
      "webOrigins": ["https://ha-bridge.breathtechnology.co.uk"],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "+"
      }
    }
  ],
  "clientScopes": [
    { "name": "roles",   "protocol": "openid-connect" },
    { "name": "profile", "protocol": "openid-connect" },
    { "name": "email",   "protocol": "openid-connect" },
    { "name": "microprofile-jwt", "protocol": "openid-connect" }
  ],
  "defaultDefaultClientScopes": ["roles", "profile", "email"],
  "defaultOptionalClientScopes": ["microprofile-jwt"],
  "roles": {
    "realm": [
      { "name": "admin", "description": "Realm admin for Breath" },
      { "name": "user",  "description": "Standard user" }
    ]
  }
}

Import and verify:

kcadm.sh config credentials --server https://idp.breathtechnology.co.uk --realm master \
  --user director --password 'CHANGEME_ADMIN'

kcadm.sh create realms -f /home/director/breath-realm.json
curl -sS https://idp.breathtechnology.co.uk/realms/breath/.well-known/openid-configuration | jq .issuer
# expect: "https://idp.breathtechnology.co.uk/realms/breath"

Add an admins group with console rights inside mygroup:

GID=$(kcadm.sh create groups -r mygroup -s name=admins -i)
RMID=$(kcadm.sh get clients -r mygroup --fields id,clientId | jq -r '.[] | select(.clientId=="realm-management") | .id')
kcadm.sh add-roles -r breath --gid "$GID" --cid "$RMID" --rolename realm-admin

Add users to the group as needed and head over to KEYCLOAK and see the new Realm


8) Protect Ollama with oauth2‑proxy (homer, Arch)

Install & configure:

Do this on your Ollama host, this becomes your access point to access ollama's API once you have run ollama pull gpt-oss:20b and then ollama serve

For those who have not tried gpt-oss yet, head over here and have a look, its a pretty cool GPT5 based GPT, it works and functions just like ChatGPT5 while you do need ideally a 16GB Nvidia video card it runs acceptably on an RTX3060 12GB LINK

pacman -Syu --noconfirm oauth2-proxy nginx

install -d -m 0750 /etc/oauth2-proxy
cat >/etc/oauth2-proxy/ollama.conf <<'EOF'
provider = "oidc"
oidc_issuer_url = "https://idp.breathtechnology.co.uk/realms/breath"
email_domains = [ "*" ]

client_id = "ollama-gw"
client_secret = "CHANGEME_OIDC_SECRET"
cookie_secret = "CHANGEME_32BYTE_BASE64_OR_HEX"

reverse_proxy = true
upstreams = [ "http://[::1]:11434" ]
http_address = "[::1]:4180"

cookie_secure = true
cookie_samesite = "lax"
set_xauthrequest = true
pass_user_headers = true
pass_authorization_header = true
silence_ping_logging = true
EOF

# systemd override to run this config
systemctl edit oauth2-proxy
# add
# [Service]
# ExecStart=
# ExecStart=/usr/bin/oauth2-proxy --config /etc/oauth2-proxy/ollama.conf

systemctl daemon-reload
systemctl enable --now oauth2-proxy

NGINX vhost for ollama.breathtechnology.co.uk:

# /etc/nginx/http.d/ollama.conf
limit_req_zone $binary_remote_addr zone=rl_ollama:10m rate=5r/s;

server {
  listen [::]:80 ipv6only=on;
  server_name ollama.breathtechnology.co.uk;
  return 301 https://$host$request_uri;
}

server {
  listen [::]:443 ssl ipv6only=on;
  server_name ollama.breathtechnology.co.uk;

  ssl_certificate     /etc/letsencrypt/live/ollama.breathtechnology.co.uk/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/ollama.breathtechnology.co.uk/privkey.pem;

  # oauth2-proxy endpoints
  location = /oauth2/auth { proxy_pass http://[::1]:4180/oauth2/auth; proxy_set_header X-Forwarded-Uri $request_uri; }
  location /oauth2/      { proxy_pass http://[::1]:4180; }

  location / {
    limit_req zone=rl_ollama burst=20 nodelay;

    auth_request /oauth2/auth;

    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Uri   $request_uri;
    proxy_set_header X-Real-IP         $remote_addr;

    # pass identity to upstream if desired
    auth_request_set $user $upstream_http_x_auth_request_user;
    proxy_set_header X-User $user;

    proxy_pass http://[::1]:4180;  # oauth2-proxy forwards to Ollama
  }
}

Reload:

nginx -t && systemctl restart nginx

Get the client secret for ollama-gw and place into the config:

CID=$(kcadm.sh get clients -r myclients -q clientId=ollama-gw --fields id --format csv --noquotes)
kcadm.sh get clients/$CID/client-secret -r myclients
vim /etc/oauth2-proxy/ollama.conf   # set client_secret, cookie_secret
systemctl restart oauth2-proxy

9) nftables (IPv6 edge sketch)

table inet filter {
  chain input {
    type filter hook input priority 0;
    iifname "eth0" ip6 daddr 2a02:8012:bc57:cafe::babe tcp dport {443} accept
    iifname "eth0" ip6 daddr 2a02:8012:bc57:cafe::babe drop comment "drop rest to idp"
  }
}

Adjust interface/address as needed. Add logging/rate limits to taste.


10) Backups & exports

PostgreSQL:

install -d -m 0700 /root/pgdumps
sudo -iu postgres pg_dump -Fc keycloak > /root/pgdumps/keycloak-$(date +%F).dump

Keycloak realm export: (from Admin Console → Export), or CLI:

# master realm export (example)
# kcadm has limited export; prefer console or kc.sh export in maintenance window

Keep copies of:

  • /etc/keycloak/keycloak.conf
  • /etc/nginx/nginx.conf, /etc/nginx/stream.conf, /etc/nginx/http.d/*.conf
  • /etc/systemd/system/keycloak.service.d/override.conf (no passwords left inside)

11) Troubleshooting (field‑tested)

  • Issuer shows http:// or includes :8443: set in keycloak.conf:
    • hostname, hostname-url, hostname-admin-urlhttps://idp...
    • hostname-port=443
    • proxy=edge, hostname-strict=true, hostname-strict-https=true
  • CSP / 3p-cookies iframe errors: ensure full HTTPS path via NGINX stream; do not add conflicting CSP headers in NGINX. Keycloak emits its own CSP.
  • 400/SSL_ERROR_RX_RECORD_TOO_LONG: browser hitting TLS on a plain HTTP listener. Use stream passthrough to KC TLS. Ensure http-enabled=false and stream proxies to 8443.
  • Connection refused to 443: verify NGINX stream is listening [::]:443 and KC is on 127.0.0.1:8443.
  • Name resolves to 172.17.x or fe80:: fix /etc/hosts, avoid FQDN as system hostname, prefer IPv6 in /etc/gai.conf.
  • Keycloak won’t bind 8443: file perms/paths for certs; copy certs to /etc/keycloak/certs/ owned by keycloak.
  • Locked out of admin: add temporary KEYCLOAK_ADMIN env, restart, login, grant roles, then remove env and restart.
  • Hard reset: stop KC, dropdb keycloak; createdb keycloak -O keycloak, restart KC with bootstrap env.

12) What to rotate after first login

  • Replace all CHANGEME_… secrets.
  • Create a second break‑glass admin and store offline.
  • Wire SMTP and enable email verification if desired.
  • Tighten token and session lifetimes per app requirements.

13) Appendix – Quick smoke tests

# Keycloak health (management interface)
curl -sS http://[::1]:9000/health/ready

# Discovery
curl -sS https://idp.breathtechnology.co.uk/realms/breath/.well-known/openid-configuration | jq .issuer

# oauth2-proxy
curl -I https://ollama.breathtechnology.co.uk/
# expect 401 → redirect to IdP; after login, 200 through to Ollama

Result: a lean, IPv6‑first, reproducible Keycloak deployment on Arch/Alpine, fronted by NGINX with end‑to‑end TLS, backed by PostgreSQL, and used as an internal IdP to secure an Ollama API via oauth2‑proxy.