Clément Désiles 86ad271b23 fix(grafana): align NVMe temperature thresholds with 0-70C rating
Dashboard temperature panel now uses NVMe-specific thresholds (orange
60, red 70) instead of HDD ones; alert descriptions reference the
rated range.
2026-09-29 12:25:56 +02:00
2025-11-14 00:21:56 +01:00
2025-07-25 20:23:54 +02:00
2026-05-31 22:42:12 +02:00
2025-07-25 20:11:17 +02:00
2026-09-15 17:49:33 +02:00

Homelab Ansible Playbooks

This repository contains Ansible playbooks and roles I use to manage my NAS and some VMs 👨‍💻.

This project is designed for personal/familial scale maintenance, if you find this useful for your use, want to share advises or security concerns, feel free to drop me a line.

This is a good playground to learn and I encourage you to adapt these roles to your needs. While they might not be production-ready for all environments, I'm open to adapting them for Ansible Galaxy if there's community interest!

Architecture Overview

Platform Support: Arch Linux, Debian/Ubuntu, Darwin (partial)

Core Design:

  • A unique system administrator ({{ ansible_user }})
  • Security hardened sshd
  • Shared services pattern: Single PostgreSQL and Valkey (Redis) instances serve all services
  • Rootless Podman: Containers run as {{ ansible_user }} (daemonless, sudo podman ps shows nothing)
  • User systemd services: systemctl --user status <service> with lingering enabled
  • Nginx reverse proxy for web services
  • IP Freebind when available (e.g. unbound does not wait for wireguard to be up to start resolving DNS)

Available Services:

Service Description
dns Unbound caching DNS + Pi-hole ad blocking + VPN resolver
nfs Network file system server
zfs ZFS installation and management
uptime-kuma Uptime monitoring
ntfy Notification server
gitea Git server
immich Photo management
static-web Static website hosting
vpn WireGuard server

And more.

Port Reservation Rules

Reserved ports that must not be used as role defaults:

Port(s) Protocol Reserved for
80 tcp Nginx
443 tcp Nginx
3000-3009 tcp Testing
4430 tcp Testing
8080 tcp Testing

When adding a new role, pick a default port outside these ranges.

Requirements

Ansible >=2.15

Base tools:

# linux
apt-get install ansible ansible-lint ansible-galaxy
pacman -Syu ansible ansible-lint ansible-galaxy
# darwin
brew install ansible ansible-lint ansible-galaxy
# windows
choco install ansible ansible-lint ansible-galaxy

Other roles:

ansible-galaxy collection install -r requirements.yml

Usage

If you have a password on your ssh key --ask-pass is recommended, --ask-become-pass is always asked in these roles, as most tasks require elevated privileges. These are dropped time to time when the default user privilege is enough.

ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \
--ask-become-pass \
--ask-vault-pass

You can also call you ssh agent to unlock your key prior to simplify your calls:

ssh-add ~/.ssh/my_key
# unlock it
ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-become-pass

Bootstrapping a new host

For fresh hosts (only root available, no admin user yet):

ansible-playbook playbooks/bootstrap.yml -l <hostname> --ask-pass

This installs Python and sudo, creates {{ ansible_user }} with sudo rights, and copies your local ~/.ssh/id_ed25519.pub. Supports Arch Linux and Debian/Ubuntu.

To use a different SSH key:

ansible-playbook playbooks/bootstrap.yml -l <hostname> --ask-pass \
  --extra-vars 'bootstrap_ssh_public_key="ssh-ed25519 AAAA..."'

Then set a password for the new user (required for sudo --ask-become-pass):

ssh root@<hostname> passwd jambon

After that, run the host playbook normally:

ansible-playbook playbooks/<hostname>.yml --ask-become-pass

Developping

Linting:

ansible-lint
npx prettier --write .

Contributing

Role Types

Type Examples Structure
Containerized web service immich, gitea, ntfy, uptime_kuma defaults/, tasks/, handlers/, templates/{<svc>.yaml.j2, <svc>.service.j2, nginx-vhost.conf.j2}, meta/
Shared database (host-level) postgres, valkey Single instance, per-app isolated creds, binds 127.0.0.1, per-app users NOSUPERUSER,NOCREATEDB,NOCREATEROLE
Native system service grafana, prometheus, nginx Like DB services, may include an nginx vhost

Container service task order: password validation → DB/Valkey setup → project dir → config files → kube YAML → getent home → systemd user dir → service unit → lingering → enable/start → nginx vhost.

Required Patterns

Password validation (any role needing secrets):

- name: Validate required passwords are set
  ansible.builtin.assert:
    that:
      - myservice_password is defined
      - myservice_password | length >= 12
    fail_msg: |-
      myservice_password is required (min 12 chars).
      See roles/myservice/defaults/main.yml.

Leave passwords undefined in defaults/main.yml (# myservice_password: "" # Intentionally undefined).

User home directory — resolve via getent, not ansible_env.HOME:

- ansible.builtin.getent: { database: passwd, key: "{{ ansible_user }}" }
- ansible.builtin.set_fact:
    user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"

Systemd user service — rootless, Type=notify, --service-container=true, restartPolicy: Never in the pod manifest:

[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/myservice
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
ExecStop=/usr/bin/podman kube down myservice.yaml
Restart=on-failure
[Install]
WantedBy=default.target

Containers reach host 127.0.0.1 services at {{ podman_gw_gateway }} (default 100.64.0.1). Pasta is configured globally in roles/podman/templates/containers.conf.j2.

PostgreSQL for apps — community.postgresql.postgresql_user (with NOSUPERUSER,NOCREATEDB,NOCREATEROLE) → postgresql_db (owned by the app user) → postgresql_privs (ALL on schema public), all as become_user: "{{ postgres_admin_user }}".

Valkey ACL user — define myservice_valkey_acl in defaults and add it to the valkey_acl_users list in inventory:

myservice_valkey_acl:
  username: "{{ myservice_valkey_user }}"
  password: "{{ myservice_valkey_password }}"
  keypattern: "myservice_*"
  commands: "&* -@dangerous +@read +@write +@pubsub +select +auth +ping +info"

Nginx vhost — deploy to {{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/myservice.conf, gated by myservice_nginx_enabled, notify: Reload nginx; remove with state: absent when disabled.

OS-specific variables — vars/archlinux.yml / vars/debian.yml, loaded with:

- ansible.builtin.include_vars: "{{ item }}"
  with_first_found:
    - "{{ ansible_facts['os_family'] }}.yml"
    - debian.yml

Conventions

  • Meta deps: only always-required (podman, postgres). Never optional (nginx uses a *_nginx_enabled flag).
  • Naming: variables snake_case, tasks/handlers Capitalized, files kebab-case.
  • Idempotency: changed_when: false for reads, creates: for commands that produce files, set -o pipefail in shell.
  • Permissions: config 0644, secrets 0600/0640, dirs 0755, data 0750, systemd units 0644.
  • Ports: pick a default outside the reserved ranges above.

Q&A

Immich crash loop: PostgresError: must be owner of extension vector

Immich tries to self-update the pgvector extension at startup, but its database user is intentionally NOSUPERUSER, so the ALTER EXTENSION vector UPDATE call fails and the microservices worker exits with code 1.

Fix it on the running host by updating the extension as the postgres superuser:

sudo -u postgres psql -d immich -c 'ALTER EXTENSION vector UPDATE;'

The Immich role also runs this automatically on subsequent playbook runs, so re-deployments after a pgvector package upgrade do not require manual intervention.

S
Description
Homelab Ansible Playbooks and Roles
Readme MIT
725 KiB
Languages
Shell 55.5%
Jinja 44.5%