Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy

Replaces the single shared HTTP Basic service-account credential (which
caused a production outage from a username mismatch) with per-user login:
Keycloak (already running on this VM for gcnm, now also fronted on
auth.friessn.de with its own "homekeeper" realm) authenticates the user
once via the landing page, FastAPI verifies the OIDC id_token and mints
its own signed session JWT as a cookie, and both Shiny apps forward that
per-session token as a Bearer credential instead of a static shared one.
Authorization is a simple ALLOWED_USERS allowlist; the old auth.users
table and bcrypt seeding are gone entirely.

Also carries forward the in-progress rootless Podman/Quadlet migration
(gitea, homekeeper, podman roles) and fixes a pre-existing bug where
each role's handlers were malformed inside tasks/main.yml instead of
their own handlers/main.yml, which broke ansible-playbook entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
This commit is contained in:
friessn
2026-07-13 06:31:36 +00:00
parent 58983e6855
commit 6c9cd67a08
36 changed files with 579 additions and 245 deletions
+24 -20
View File
@@ -1,24 +1,28 @@
# Homekeeper Infrastructure
Ansible setup for a Hetzner VM (Ubuntu 24.04) running Homekeeper via Podman.
Ansible setup for a Hetzner VM (Ubuntu 24.04) running Homekeeper via rootless Podman.
## Architecture
```
nginx (system) ← HTTPS, routes by path
├── / ← static landing page (/var/www/html)
├── /gitea/Gitea on :3000 (git + container registry)
├── /api/ → homekeeper-api on :8000
├── /beekeeper/ → homekeeper-beekeeper on :3838
└── /listkeeper/ → homekeeper-listkeeper on :3839
nginx (system) ← HTTPS
├── home.friessn.de ← landing page (/var/www/html) + path routing
├── / static landing page
├── /api/ → homekeeper-api on 127.0.0.1:8000
├── /beekeeper/ → homekeeper-beekeeper on 127.0.0.1:3838
└── /listkeeper/ → homekeeper-listkeeper on 127.0.0.1:3839
└── git.friessn.de → Gitea on 127.0.0.1:3000 (git + container registry)
own site file, managed outside this role
Podman (rootful, Quadlets → systemd units)
├── homekeeper-db postgres:17, data at /opt/homekeeper/pg_data
Podman (rootless, user Quadlets under {{ deploy_user }} → systemd --user units)
├── homekeeper-db postgres:17, data in named volume {{ homekeeper_db_volume }}
├── homekeeper-api from Gitea registry, auto-update enabled
├── homekeeper-beekeeper from Gitea registry, auto-update enabled
── homekeeper-listkeeper from Gitea registry, auto-update enabled
── homekeeper-listkeeper from Gitea registry, auto-update enabled
└── gitea from docker.io, data in named volume {{ gitea_data_volume }}
gitea container from docker.io, auto-update disabled
All containers run as {{ deploy_user }} (not root) — `loginctl enable-linger`
keeps the user systemd instance (and its containers) running after logout/reboot.
```
## First-time setup
@@ -54,16 +58,16 @@ On your local machine, build and push the three custom images:
```bash
# Login to Gitea registry
docker login git.friessn.de -u nico
docker login git.friessn.de -u friessn
# Build and push
docker build -t git.friessn.de/nico/homekeeper-api:latest ./api
docker build -t git.friessn.de/nico/homekeeper-beekeeper:latest ./beekeeper
docker build -t git.friessn.de/nico/homekeeper-listkeeper:latest ./listkeeper
docker build -t git.friessn.de/friessn/homekeeper-api:latest ./api
docker build -t git.friessn.de/friessn/homekeeper-beekeeper:latest ./beekeeper
docker build -t git.friessn.de/friessn/homekeeper-listkeeper:latest ./listkeeper
docker push git.friessn.de/nico/homekeeper-api:latest
docker push git.friessn.de/nico/homekeeper-beekeeper:latest
docker push git.friessn.de/nico/homekeeper-listkeeper:latest
docker push git.friessn.de/friessn/homekeeper-api:latest
docker push git.friessn.de/friessn/homekeeper-beekeeper:latest
docker push git.friessn.de/friessn/homekeeper-listkeeper:latest
```
### 6. Full playbook run
@@ -76,9 +80,9 @@ ansible-playbook -i inventory/hosts.yml site.yml
After pushing a new image to the Gitea registry, `podman auto-update` picks it up
automatically within `autoupdate_schedule` (default: every 15 minutes).
For an immediate deploy:
For an immediate deploy (containers run rootless as `deploy_user`, not root):
```bash
ssh root@YOUR_IP podman auto-update
ssh nico@YOUR_IP podman auto-update
```
## Secrets management
+53 -10
View File
@@ -2,28 +2,71 @@
# Main domain (apps + landing page)
domain: home.friessn.de
# Everything runs rootless — one Linux user owns all Podman Quadlets
# (systemd --user units under ~/.config/containers/systemd), started via
# `loginctl enable-linger` so they survive logout/reboot without root.
deploy_user: nico
deploy_uid: 1000
# Gitea on a subdomain — cleaner than a subpath, required for container registry
gitea_domain: git.friessn.de
gitea_data_dir: /opt/gitea
gitea_data_volume: gitea-data # named Podman volume, not a bind mount (avoids rootless UID mapping issues)
gitea_http_port: 3000 # internal port, nginx proxies HTTPS → here
gitea_admin_user: nico
gitea_ssh_port: 2222 # published directly (nginx can't proxy SSH)
gitea_admin_user: friessn # actual Gitea account created during the install wizard
gitea_admin_password: CHANGE_ME # replace — set via ansible-vault in production
gitea_admin_email: nico.friess@googlemail.com
# Container registry — Gitea's built-in OCI registry, same host as Gitea
# Images: git.friessn.de/nico/homekeeper-api:latest
# Container registry — Gitea's built-in OCI registry, same host as Gitea, over HTTPS (443)
# Images: git.friessn.de/friessn/homekeeper-api:latest
registry_host: "{{ gitea_domain }}"
registry_user: "{{ gitea_admin_user }}"
registry_token: CHANGE_ME # Gitea API token with package:write — set after first Gitea start
registry_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
33626439663535643963613436373132336333633839613965396539653834313831383039323961
3339613430323237636532663665313331303735623137660a613032306362343435633034383965
61383131393939393561366335376339343432636431663033363036346337633865363561303238
3339313363656137380a663264346666346166663362656638343838643333626438646162643835
39656564316130383766656364303035343061653164356664643433336531363334396530376432
3162326539326134323639363661363165323632383932623434
# Homekeeper app
homekeeper_data_dir: /opt/homekeeper
homekeeper_db_volume: homekeeper-db-data # named Podman volume
db_name: homestead
db_user: homestead
db_password: CHANGE_ME # replace — set via ansible-vault in production
api_user: homestead
api_pass: CHANGE_ME # replace
initial_users: "nico:CHANGE_ME" # user:password pairs for API auth
# Auto-update timer interval (OnCalendar syntax)
# Auth — Keycloak (already running on this VM for the unrelated gcnm app,
# published on 127.0.0.1:8080) acts as OIDC identity provider, exposed here
# on its own subdomain with its own realm so it's cleanly separated from
# gcnm's realm. FastAPI verifies the Keycloak login once via the "homekeeper"
# realm, then mints its own session JWT — see api/app/auth.py.
# NOTE: the Keycloak container itself is not managed by this repo/role — only
# the nginx site + the "homekeeper" realm/client (created manually in the
# Keycloak admin console) belong to this setup.
keycloak_domain: auth.friessn.de
keycloak_realm: homekeeper
# This Keycloak instance serves under a /auth path prefix (KC_HTTP_RELATIVE_PATH=/auth
# in gcnm's docker-compose setup) — not at root.
oidc_issuer_url: "https://{{ keycloak_domain }}/auth/realms/{{ keycloak_realm }}"
oidc_client_id: homekeeper
oidc_client_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
66323965333830653838356233623461323835303663353530396166653330303130616231323262
3233626366633532643434326338313835646533343934640a333037646138393531613730616633
64653330623639376164353033663061636436323465646662333934306337353765343739313561
3631333636643965370a663464333430306539396164363466633636643466383461636463626631
30343732363664356165363565393565346439646235313637346164376265326635613132663132
6232663636656233653466393338333532383764616633653234
session_jwt_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
64613365363266313061396438313965646462313630636631353236353032383532376631303138
6663626664346538363464386333383136306165343163320a653763633839353637613136333464
62373231363664633935373632346161613865623930613436306661323439656462323538623864
6136343462376436320a666463346530653062323431626135666665373135393262633766353264
65303164373366393635653964316664633031313536383965353330343936623532
allowed_users: "nico" # comma-separated Keycloak usernames allowed to log in
# Auto-update timer interval (OnCalendar syntax) — rootless uses podman-auto-update.timer
# in the user systemd instance, same schedule override mechanism as the system one.
autoupdate_schedule: "*:*:0/10" # every 10 seconds
+2 -3
View File
@@ -2,6 +2,5 @@
all:
hosts:
homekeeper:
ansible_host: YOUR_HETZNER_IP # replace with actual IP
ansible_user: root
# ansible_ssh_private_key_file: ~/.ssh/id_ed25519
ansible_host: localhost
ansible_connection: local # this repo is normally run from the target VM itself
@@ -0,0 +1,18 @@
---
- name: Reload gitea user systemd
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
daemon_reload: true
scope: user
- name: Restart gitea
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
name: gitea
state: restarted
enabled: true
scope: user
+13 -15
View File
@@ -1,26 +1,24 @@
---
- name: Create Gitea quadlet directory
# Gitea runs rootless as {{ deploy_user }} — see group_vars/all.yml.
- name: Enable linger for {{ deploy_user }} (user services survive logout/reboot)
command: "loginctl enable-linger {{ deploy_user }}"
changed_when: false
- name: Create user Quadlet directory
file:
path: /etc/containers/systemd
path: "/home/{{ deploy_user }}/.config/containers/systemd"
state: directory
mode: "0755"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
- name: Deploy Gitea container quadlet
template:
src: gitea.container.j2
dest: /etc/containers/systemd/gitea.container
dest: "/home/{{ deploy_user }}/.config/containers/systemd/gitea.container"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0644"
notify:
- Reload systemd
- Reload gitea user systemd
- Restart gitea
handlers:
- name: Reload systemd
systemd:
daemon_reload: true
- name: Restart gitea
systemd:
name: gitea
state: restarted
enabled: true
@@ -6,11 +6,13 @@ After=network-online.target
Image=docker.io/gitea/gitea:latest
ContainerName=gitea
PublishPort=127.0.0.1:{{ gitea_http_port }}:3000
Volume={{ gitea_data_dir }}:/data:Z
Environment=USER_UID=1000
Environment=USER_GID=1000
PublishPort={{ gitea_ssh_port }}:22
Volume={{ gitea_data_volume }}:/data
Environment=USER_UID={{ deploy_uid }}
Environment=USER_GID={{ deploy_uid }}
Environment=GITEA__server__DOMAIN={{ gitea_domain }}
Environment=GITEA__server__ROOT_URL=https://{{ gitea_domain }}/
Environment=GITEA__server__SSH_DOMAIN={{ gitea_domain }}
Environment=GITEA__server__HTTP_PORT=3000
Environment=GITEA__packages__ENABLED=true
Environment=GITEA__packages__CHUNKED_UPLOAD_PATH=/data/tmp/package-upload
@@ -20,4 +22,4 @@ Restart=always
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target default.target
WantedBy=default.target
@@ -0,0 +1,23 @@
---
- name: Reload homekeeper user systemd
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
daemon_reload: true
scope: user
- name: Restart homekeeper
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
name: "{{ item }}"
state: restarted
enabled: true
scope: user
loop:
- homekeeper-db
- homekeeper-api
- homekeeper-beekeeper
- homekeeper-listkeeper
+13 -22
View File
@@ -1,21 +1,28 @@
---
- name: Create quadlet directory
# Homekeeper containers run rootless as {{ deploy_user }}, same pattern as the gitea role.
- name: Create user Quadlet directory
file:
path: /etc/containers/systemd
path: "/home/{{ deploy_user }}/.config/containers/systemd"
state: directory
mode: "0755"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
- name: Deploy homekeeper network quadlet
template:
src: homekeeper.network.j2
dest: /etc/containers/systemd/homekeeper.network
dest: "/home/{{ deploy_user }}/.config/containers/systemd/homekeeper.network"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0644"
notify: Reload systemd
notify: Reload homekeeper user systemd
- name: Deploy container quadlets
template:
src: "{{ item }}.j2"
dest: "/etc/containers/systemd/{{ item }}"
dest: "/home/{{ deploy_user }}/.config/containers/systemd/{{ item }}"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0644"
loop:
- homekeeper-db.container
@@ -23,21 +30,5 @@
- homekeeper-beekeeper.container
- homekeeper-listkeeper.container
notify:
- Reload systemd
- Reload homekeeper user systemd
- Restart homekeeper
handlers:
- name: Reload systemd
systemd:
daemon_reload: true
- name: Restart homekeeper
systemd:
name: "{{ item }}"
state: restarted
enabled: true
loop:
- homekeeper-db
- homekeeper-api
- homekeeper-beekeeper
- homekeeper-listkeeper
@@ -13,7 +13,14 @@ Environment=DB_NAME={{ db_name }}
Environment=DB_USER={{ db_user }}
Environment=DB_PASSWORD={{ db_password }}
Environment=ROOT_PATH=/api
Environment=INITIAL_USERS={{ initial_users }}
Environment=ENV=production
Environment=COOKIE_SECURE=true
Environment=PUBLIC_BASE_URL=https://{{ domain }}
Environment=OIDC_ISSUER_URL={{ oidc_issuer_url }}
Environment=OIDC_CLIENT_ID={{ oidc_client_id }}
Environment=OIDC_CLIENT_SECRET={{ oidc_client_secret }}
Environment=SESSION_JWT_SECRET={{ session_jwt_secret }}
Environment=ALLOWED_USERS={{ allowed_users }}
AutoUpdate=registry
Label=io.containers.autoupdate=registry
@@ -22,4 +29,4 @@ Restart=always
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target default.target
WantedBy=default.target
@@ -8,8 +8,6 @@ ContainerName=homekeeper-beekeeper
Network=homekeeper.network
PublishPort=127.0.0.1:3838:3838
Environment=API_URL=http://homekeeper-api:8000
Environment=API_USER={{ api_user }}
Environment=API_PASS={{ api_pass }}
AutoUpdate=registry
Label=io.containers.autoupdate=registry
@@ -18,4 +16,4 @@ Restart=always
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target default.target
WantedBy=default.target
@@ -6,7 +6,7 @@ After=network-online.target
Image=docker.io/library/postgres:17
ContainerName=homekeeper-db
Network=homekeeper.network
Volume={{ homekeeper_data_dir }}/pg_data:/var/lib/postgresql/data:Z
Volume={{ homekeeper_db_volume }}:/var/lib/postgresql/data
Environment=POSTGRES_DB={{ db_name }}
Environment=POSTGRES_USER={{ db_user }}
Environment=POSTGRES_PASSWORD={{ db_password }}
@@ -20,4 +20,4 @@ Restart=always
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target default.target
WantedBy=default.target
@@ -9,8 +9,6 @@ Network=homekeeper.network
PublishPort=127.0.0.1:3839:3839
Environment=PORT=3839
Environment=API_URL=http://homekeeper-api:8000
Environment=API_USER={{ api_user }}
Environment=API_PASS={{ api_pass }}
AutoUpdate=registry
Label=io.containers.autoupdate=registry
@@ -19,4 +17,4 @@ Restart=always
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target default.target
WantedBy=default.target
@@ -0,0 +1,5 @@
---
- name: Reload nginx
service:
name: nginx
state: reloaded
+40 -14
View File
@@ -14,37 +14,63 @@
state: absent
notify: Reload nginx
- name: Deploy homekeeper nginx config
# git.friessn.de already has its own site file (deployed manually before this
# role existed, same one-file-per-domain convention as the other sites on this
# box) — this role only manages home.friessn.de.
- name: Deploy home.friessn.de nginx config
template:
src: homekeeper.conf.j2
dest: /etc/nginx/sites-available/homekeeper.conf
src: home.friessn.de.conf.j2
dest: /etc/nginx/sites-available/home.friessn.de
mode: "0644"
notify: Reload nginx
- name: Enable homekeeper nginx site
- name: Enable home.friessn.de nginx site
file:
src: /etc/nginx/sites-available/homekeeper.conf
dest: /etc/nginx/sites-enabled/homekeeper.conf
src: /etc/nginx/sites-available/home.friessn.de
dest: /etc/nginx/sites-enabled/home.friessn.de
state: link
notify: Reload nginx
- name: Obtain Let's Encrypt certificates
- name: Obtain Let's Encrypt certificate for {{ domain }}
command: >
certbot --nginx -d {{ domain }} -d {{ gitea_domain }}
certbot --nginx -d {{ domain }}
--non-interactive --agree-tos -m {{ gitea_admin_email }}
--redirect
args:
creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem
notify: Reload nginx
# Keycloak itself runs as a plain Docker container for the unrelated gcnm
# app (not managed by this role) — it's already published on
# 127.0.0.1:8080, this just fronts it with TLS on its own subdomain so
# Homekeeper (and anything else on the box) can treat it as a normal OIDC
# provider. Requires a DNS record for {{ keycloak_domain }} pointing at this
# VM before the certbot step below can succeed.
- name: Deploy auth.friessn.de nginx config
template:
src: auth.friessn.de.conf.j2
dest: /etc/nginx/sites-available/{{ keycloak_domain }}
mode: "0644"
notify: Reload nginx
- name: Enable auth.friessn.de nginx site
file:
src: /etc/nginx/sites-available/{{ keycloak_domain }}
dest: /etc/nginx/sites-enabled/{{ keycloak_domain }}
state: link
notify: Reload nginx
- name: Obtain Let's Encrypt certificate for {{ keycloak_domain }}
command: >
certbot --nginx -d {{ keycloak_domain }}
--non-interactive --agree-tos -m {{ gitea_admin_email }}
--redirect
args:
creates: /etc/letsencrypt/live/{{ keycloak_domain }}/fullchain.pem
notify: Reload nginx
- name: Enable nginx
service:
name: nginx
enabled: true
state: started
handlers:
- name: Reload nginx
service:
name: nginx
state: reloaded
@@ -0,0 +1,17 @@
# ── Keycloak (shared identity provider — homekeeper realm lives here,
# the gcnm app has its own separate realm): {{ keycloak_domain }} ─────────
server {
listen 80;
server_name {{ keycloak_domain }};
# certbot --nginx adds SSL redirect + listen 443 block here
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
@@ -3,7 +3,7 @@ map $http_upgrade $connection_upgrade {
'' close;
}
# ── Main app: friessn.de ────────────────────────────────────────────────────
# ── Main app: {{ domain }} ───────────────────────────────────────────────────
server {
listen 80;
server_name {{ domain }};
@@ -57,21 +57,3 @@ server {
proxy_send_timeout 86400s;
}
}
# ── Gitea: git.friessn.de ───────────────────────────────────────────────────
server {
listen 80;
server_name {{ gitea_domain }};
# certbot --nginx adds SSL redirect + listen 443 block here
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:{{ gitea_http_port }};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -0,0 +1,15 @@
---
- name: Reload systemd
systemd:
daemon_reload: true
- name: Restart podman services
command: systemctl daemon-reload
- name: Reload user systemd
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
daemon_reload: true
scope: user
+29 -22
View File
@@ -4,18 +4,14 @@
name:
- podman
- podman-compose # for ad-hoc use; production uses quadlets
- slirp4netns # rootless networking / port publishing
- uidmap # rootless subuid/subgid mapping
state: present
update_cache: true
- name: Create homekeeper data directories
file:
path: "{{ item }}"
state: directory
mode: "0750"
loop:
- "{{ homekeeper_data_dir }}"
- "{{ homekeeper_data_dir }}/pg_data"
- "{{ gitea_data_dir }}"
# All app data lives in named Podman volumes (created implicitly on first
# `podman run`/Quadlet start), not host bind mounts — avoids rootless UID
# mapping headaches. Nothing to pre-create here.
- name: Configure Gitea as additional registry
template:
@@ -25,35 +21,46 @@
notify: Restart podman services
- name: Login to Gitea container registry
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
command: >
podman login {{ registry_host }}:{{ registry_port }}
podman login {{ registry_host }}
-u {{ registry_user }} -p {{ registry_token }}
register: login_result
changed_when: "'Login Succeeded' in login_result.stdout"
# Run this after Gitea is up and registry_token is set
- name: Enable podman-auto-update timer
- name: Enable linger for {{ deploy_user }} (user services survive logout/reboot)
command: "loginctl enable-linger {{ deploy_user }}"
changed_when: false
- name: Enable podman-auto-update timer (user scope)
become_user: "{{ deploy_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
systemd:
name: podman-auto-update.timer
enabled: true
state: started
scope: user
daemon_reload: true
- name: Override auto-update timer schedule
- name: Override auto-update timer schedule (user scope)
become_user: "{{ deploy_user }}"
file:
path: "/home/{{ deploy_user }}/.config/systemd/user/podman-auto-update.timer.d"
state: directory
mode: "0755"
- name: Deploy auto-update timer schedule override
become_user: "{{ deploy_user }}"
copy:
dest: /etc/systemd/system/podman-auto-update.timer.d/override.conf
dest: "/home/{{ deploy_user }}/.config/systemd/user/podman-auto-update.timer.d/override.conf"
content: |
[Timer]
OnCalendar=
OnCalendar={{ autoupdate_schedule }}
AccuracySec=1s
mode: "0644"
notify: Reload systemd
handlers:
- name: Reload systemd
systemd:
daemon_reload: true
- name: Restart podman services
command: systemctl daemon-reload
notify: Reload user systemd
@@ -1,3 +1,3 @@
[[registry]]
location = "{{ registry_host }}:{{ registry_port }}"
location = "{{ registry_host }}"
insecure = false