Compare commits

..

75 Commits

Author SHA1 Message Date
Clément Désiles 21f47196cf feat(net_config): support IPForward and IPMasquerade in network config 2026-06-19 23:55:02 +02:00
Clément Désiles e4ad6888b6 Introduce vault pass in the doc to push a good practice 2026-06-19 23:52:19 +02:00
Clément Désiles 4c57d28b4d Add new fdroid role to host custom apks 2026-06-19 23:50:56 +02:00
Clément Désiles 045c0b9bec refactor(zfs): inline dataset ownership, add absent cleanup
Drop separate dataset-ownership.yml task file. Use
extra_zfs_properties.mountpoint directly instead of zfs get.
Add rmdir cleanup for absent dataset mountpoints.
2026-06-19 23:46:33 +02:00
Clément Désiles 13b8aae769 Add support for NUT (EATON inverter) 2026-06-13 09:37:49 +02:00
Clément Désiles 25621a101c Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-06-12 22:34:24 +02:00
Clément Désiles 0726e417d2 feat: add syncthing support 2026-06-03 23:28:26 +02:00
Clément Désiles 2f3eebd422 feat: add metabase role 2026-06-03 10:01:00 +02:00
Clément Désiles d976a9d701 fix: cleanup examples 2026-05-31 22:42:12 +02:00
Clément Désiles e74fffd5fc refacto: move inventory examples to a dedicated dir 2026-05-31 22:34:31 +02:00
Clément Désiles 30dfb9ee8b feat(immich): support read-only external libraries
Add immich_external_libraries variable to mount host paths into the
server container, intended for use with Immich's External Libraries
feature. Mounts are read-only; the in-container mount_path must be
used when registering the library in the Immich UI.
2026-05-30 23:39:17 +02:00
Clément Désiles b0324cf3fe refactor: hoist OS-specific package names to role defaults
- nfs_server: nfs_package_name (nfs-utils / nfs-kernel-server)
- wireguard: wireguard_package_name (wireguard-tools / wireguard)
- tooling:   tooling_dig_package, tooling_netcat_package

Also fix tooling role structure: move tooling.yml to tasks/main.yml so
the role is actually invokable via 'role: tooling' (defaults/main.yml
is auto-loaded), and collapse the 10 individual package tasks into a
single list-based install.
2026-05-30 21:57:20 +02:00
Clément Désiles a6ca97ca0e feat(samba_server): new role for SMB/CIFS shares
Mirrors the nfs_server design: standalone tdbsam server, per-share access
control (valid_users, write_list, force_user/group), optional guest fallback
(map to guest = Bad User), UFW rules for ports 445/139, testparm-validated
config, idempotent smbpasswd user creation.
2026-05-30 21:57:13 +02:00
Clément Désiles b2a66099aa fix(immich): prevent client SocketTimeoutException on large uploads
Add missing nginx directives recommended by Immich docs for mobile
photo backup over slow links:

- http2 on: multiplex parallel asset uploads from the mobile app
- client_body_timeout / send_timeout / keepalive_timeout 600s: cover
  the client<->nginx leg (default 60s matched the Android timeout)
- proxy_request_buffering off + proxy_buffering off: stream upload
  bytes to immich as they arrive instead of buffering the whole file,
  keeping the TCP connection active and avoiding idle-socket timeouts
- proxy_connect_timeout 600s: explicit upstream connect timeout
2026-05-30 17:18:05 +02:00
Clément Désiles 314fa715fd fix(nginx): prevent cert leak on IPv6 / unknown SNI
Two issues caused TLS to break on photos.carabosse.cloud over IPv6
(GrapheneOS + Immich app via Orange 5G NAT64):

1. Per-service vhosts only listened on IPv4 (listen 443 ssl). On IPv6,
   nginx fell back to the first vhost loaded alphabetically and served
   its certificate, breaking hostname verification on every other vhost.

2. /etc/letsencrypt/{live,archive} were 0700 root:root after certbot
   created them, so the nginx worker (user http on Arch) could not read
   the chained intermediates and served the leaf-only chain.

Changes:
- Add catch-all 00-default.conf default_server on :80 and :443 (v4+v6)
  with a self-signed cert and 'return 444'. ACME challenges still
  answered on :80.
- Add IPv6 listeners ([::]:80 and [::]:443 ssl) to immich, gitea, ntfy,
  uptime_kuma vhosts and to the temporary ACME provisioning vhost.
- Apply 0755 on /etc/letsencrypt/live and /etc/letsencrypt/archive on
  every run, not only at initial cert provisioning.
2026-05-30 17:06:10 +02:00
Clément Désiles 80026fac0b fix: pin ansible.posix >=2.2.0 to silence _text deprecation warning 2026-05-30 17:05:58 +02:00
Clément Désiles c9e2ff930c feat(net_config): safer ufw restart on NAT/forwarding changes
- Replace 'ufw disable && ufw --force enable' single-shot handler with a
  block that dry-runs the ruleset, disables, re-enables, then verifies
  ufw is active. No '&&' short-circuit, so failures are loud instead of
  leaving the host firewall-less.
- Rename handler to 'Restart ufw (ip-forwarding settings changed)' to
  reflect that this is a full restart (required to pick up
  /etc/default/ufw and /etc/ufw/before.rules changes per ufw(8)).
- Add NAT/masquerade tasks: enable ipv4 forwarding, set
  DEFAULT_FORWARD_POLICY=ACCEPT, and write a per-interface *nat block
  in /etc/ufw/before.rules.
- Declare requires_ansible >=2.15 in meta/runtime.yml (handler uses
  block:, supported since 2.12; 2.15 is a safe modern floor).
- README: document Ansible version requirement, port reservation
  rules, and Immich pgvector Q&A.
2026-05-29 22:24:16 +02:00
Clément Désiles 36d6baaecb fix: missing task in wg 2026-05-29 21:54:25 +02:00
Clément Désiles 5f2c82d296 fix: use ansible_facts['getent_passwd'] to silence INJECT_FACTS_AS_VARS deprecation 2026-05-29 21:54:03 +02:00
Clément Désiles dbc7ca203a fix: minor taks name typo 2026-05-29 21:50:39 +02:00
Clément Désiles a8545fc501 fix(podman): use Type=notify + service-container so systemd sees pod crashes
The previous Type=oneshot + RemainAfterExit=true pattern made systemd
freeze pod units in 'active (exited)' as soon as 'podman play kube'
returned, so crash-looping containers were invisible to
'systemctl --user --failed' and Restart=on-failure never fired.

For every podman-pod role (immich, fdroid, ntfy, gitea, qfieldcloud,
unifi, matrix, uptime_kuma):

- switch units to Type=notify + NotifyAccess=all
- run 'podman kube play --service-container=true' so the unit's main
  PID stays alive as long as the pod
- use 'podman kube down' for ExecStop
- add TimeoutStartSec=180 to cover slow first-boot image pulls

Pod manifests: flip every container's restartPolicy from Always to
Never. systemd is now the single owner of the restart loop: container
exits -> pod dies -> service container dies -> unit fails ->
Restart=on-failure restarts everything cleanly. With Always, podman
retried internally and hid the failure from systemd.

CLAUDE.md updated to document the new canonical template and the
'restartPolicy: Never' requirement.
2026-05-29 21:49:13 +02:00
Clément Désiles 29d9f27052 fix: ntfy probe without curl available 2026-05-29 21:41:04 +02:00
Clément Désiles b04939d3d0 fix: use ansible_facts['kernel'] to avoid deprecation warning 2026-05-29 21:35:54 +02:00
Clément Désiles ff3133f8e7 feat: wireguard role allow multiple endpoints 2026-05-29 21:32:08 +02:00
Clément Désiles 4ae7721070 fix: secure pg + fix old way of sharing podman network 2026-05-29 21:31:07 +02:00
Clément Désiles ffeff6556b fix: restart dhcpd on nas restart 2026-05-29 21:29:14 +02:00
Clément Désiles 436fba0d39 Merge branch 'main' of github.com:cdesiles/ansible-playbooks 2026-05-29 21:28:41 +02:00
Clément Désiles 92deb854d2 fix: enhance tooling 2026-05-29 21:27:25 +02:00
Clément Désiles 05e7ee3956 fix: tls for static web 2026-05-29 21:27:00 +02:00
Clément Désiles aea450dc9d feat: nginx certbot 2026-05-29 21:26:17 +02:00
Clément Désiles 1d00432061 fix: podman integration 2026-05-29 21:24:58 +02:00
Clément Désiles 7904275754 nfs: minor tweak 2026-05-22 00:07:24 +02:00
Clément Désiles 305b8324db feat: sys autoupdate 2026-05-08 23:47:21 +02:00
Clément Désiles ea0771a5ac fix: update wireguard example 2026-05-05 22:59:46 +02:00
Clément Désiles 48e87f7cb1 zsh: enhance configuration with alacritty 2026-05-05 22:58:56 +02:00
Clément Désiles 4ac40b9898 fix: nginx defaults override on archlinux 2026-05-05 22:56:08 +02:00
Clément Désiles 488be1280c fix: dhcpd startup dependency 2026-05-05 22:54:56 +02:00
Clément Désiles de165f5e1c fix: review archlinux install details 2026-04-11 22:55:03 +02:00
Clément Désiles f9397ad38c feat: allow sshd to bind on multiple networks 2026-04-11 22:54:35 +02:00
Clément Désiles ac40c23d06 feat: more base tools 2026-04-11 22:53:19 +02:00
Clément Désiles 6fc7879648 fix: uptime-kuma reverse proxy config 2026-04-11 22:53:06 +02:00
Clément Désiles c4136ba5d2 fix: ntfy 2026-04-11 22:52:29 +02:00
Clément Désiles 4d60c6ea34 fix: zshrc to take p10k theme 2026-04-11 22:51:28 +02:00
Clément Désiles ae33184aa0 fix: zsh role add fzf support and p10k settings tweaks 2026-04-11 22:41:10 +02:00
Clément Désiles 61c88045f7 feat: add more tooling 2026-03-17 23:13:02 +01:00
Clément Désiles 525868caaf fix: wireguard on archlinux 2026-03-17 23:10:08 +01:00
Clément Désiles 235881aba7 fix: commit bootstrap playbook 2026-03-17 23:09:47 +01:00
Clément Désiles a6878c0b7d fix: dhcpd ipv4 service 2026-03-17 23:09:29 +01:00
Clément Désiles e209a93a78 feat: BREAKING unbound configuration 2026-03-17 23:08:44 +01:00
Clément Désiles 869727d364 fix: add bootstrap for new hosts 2026-03-17 23:06:42 +01:00
Clément Désiles 6393ff6ed3 fix: force images pull and change default ports 2026-02-14 21:02:51 +01:00
Clément Désiles 23c7da84bb fix: minor doc 2026-02-03 22:11:35 +01:00
Clément Désiles 34da95f8be fix: ntfy timezone 2026-02-03 22:08:48 +01:00
Clément Désiles 321a14a108 fix: increase unbound ttl&cache 2026-02-03 22:08:20 +01:00
Clément Désiles 1f758deb82 feat: add dhcpd server role 2026-02-03 22:07:40 +01:00
Clément Désiles 5fb027c446 fix: open wg port only in server mode 2026-01-22 07:31:45 +01:00
Clément Désiles 94dfe36c46 fix: add dig to test our dns setup 2026-01-18 13:46:37 +01:00
Clément Désiles 5a880d5d5a fix: unbound idempotency 2026-01-18 13:46:16 +01:00
Clément Désiles 8d3db69172 fix: wireguard config failfast 2026-01-18 13:21:37 +01:00
Clément Désiles aa5de65d30 fix: unbound ipv6 localhost 2026-01-18 13:21:15 +01:00
Clément Désiles c79c445a23 chore: ansible-lint review (almost done) 2026-01-04 11:21:15 +01:00
Clément Désiles 3e469fa25e fix: unbound interface naming 2025-12-24 17:09:22 +01:00
Clément Désiles 08364cf2c8 fix: unbound boot ordering 2025-12-24 16:47:38 +01:00
Clément Désiles f385efca84 doc: lint & enhancements 2025-12-23 09:11:16 +01:00
Clément Désiles 229f9f6b5d fix: user systemd 2025-12-23 09:08:43 +01:00
Clément Désiles 1cdad04a93 fix: cleanup 2025-12-21 23:04:09 +01:00
Clément Désiles 1349ce9c19 fix: defaulting to nginx vars 2025-12-21 22:26:24 +01:00
Clément Désiles 10f4eb5817 fix: podman connect 2025-12-21 22:25:57 +01:00
Clément Désiles c197f28013 fix: using a bridge to link podman pods to host s services 2025-12-21 22:25:11 +01:00
Clément Désiles b2a3ae6783 feat: add gitea support 2025-12-21 22:24:22 +01:00
Clément Désiles 10e58eb990 fix: podman user called by systemd 2025-12-20 23:14:26 +01:00
Clément Désiles ba94509bca feat: fix systemd user and add static-web role 2025-12-20 23:14:00 +01:00
Clément Désiles 787c171f65 feat: new services and fixes 2025-12-20 20:52:24 +01:00
Clément Désiles d8eb53f096 feat: add ntfy notification system 2025-12-15 23:09:47 +01:00
Clément Désiles 150a032988 fix: python interpreter relates to inventory 2025-12-15 22:15:34 +01:00
210 changed files with 9110 additions and 1410 deletions
+1
View File
@@ -1,3 +1,4 @@
--- ---
skip_list: skip_list:
- var-naming[no-role-prefix] - var-naming[no-role-prefix]
- no-handler # Sequential task flows require immediate execution, not end-of-play handlers
+4 -9
View File
@@ -1,9 +1,4 @@
inventory/* /inventory
!inventory/hosts.example /inventory_data
!inventory/host_vars/ /playbooks
inventory/host_vars/* /roadmap
!inventory/host_vars/example.yml
inventory_data/
playbooks/*
!playbooks/example.yml
TODO.md
+357
View File
@@ -0,0 +1,357 @@
# Project Architecture & Patterns
## Limit documentation
- Only one README per role and a general project README.
- Default vars are already documented in ./roles/<role>/defaults/main.yml, no need to document them elsewhere. A link to this file from ./roles/<role>/README.md is sufficient.
## Target Audience
This repository targets **power users, developers, and homelab enthusiasts** who are comfortable with:
- Command-line interfaces and SSH
- Basic networking and Linux system administration
- Reading technical documentation and following references
- Ansible concepts (roles, playbooks, inventory)
**Documentation Philosophy:**
- Straight to the point, no hand-holding
- Minimal redundancy - references between documents preferred
- Assumes familiarity with underlying technologies
- Technical accuracy over verbosity
## Overview
This Ansible repository manages NAS/homelab infrastructure with a focus on:
- **Shared services pattern**: System-level PostgreSQL, Valkey, Nginx
- **Service isolation**: Separate databases and users per service
- **Security first**: Localhost-only access, minimal privileges, fail-fast validation
- **Modularity**: Independent service deployments
## Key Architectural Decisions
### 1. Shared Services Pattern
**Why:** Resource efficiency, easier maintenance, better isolation
**Implementation:**
- Single PostgreSQL instance serves all services
- Single Valkey instance serves all services
- Each service creates its own database/user (PostgreSQL)
- Each service gets its own ACL user (Valkey)
### 2. Multi-Layer Isolation
#### PostgreSQL Isolation
Each service:
- Gets its own database
- Gets its own user with minimal privileges
- Cannot access other services' data
**Security:**
- `NOSUPERUSER`: Cannot create other superusers
- `NOCREATEDB`: Cannot create databases
- `NOCREATEROLE`: Cannot create roles
- `priv: ALL` on own database only
#### Valkey/Redis Isolation
**Important:** Valkey ACL files do NOT support comments. The `users.acl` file must contain only ACL rules, one per line. All documentation should be in role README files, not in the ACL file itself.
Each service gets its own ACL user with:
- Unique credentials (username/password)
- Key pattern restrictions (can only access specific key prefixes)
- Command restrictions (deny dangerous commands like FLUSHDB, KEYS, CONFIG)
- Database number assignment (0-15) for additional logical separation
**Security (ACL-based):**
- Key patterns: `~immich_bull*` restricts access to matching keys only
- Command groups: `-@dangerous` denies FLUSHDB, FLUSHALL, KEYS, CONFIG, etc.
- Selective grants: `+@read +@write +@pubsub` only allows necessary operations
- Channel access: `&*` for pub/sub (job queues like BullMQ)
- Lua scripting: `+eval +evalsha` only when required (e.g., BullMQ)
**Defense-in-depth:**
1. ACL users with restricted permissions (primary)
2. Database number isolation (secondary)
3. Key pattern enforcement (tertiary)
### 3. Container-to-Host Communication
**Challenge:** Containers need to reach system PostgreSQL/Valkey
**Solution:**
- Rootless Podman with `pasta` networking and `--map-host-loopback={{ podman_gw_gateway }}`
- Inside containers, the host's loopback is reachable at `{{ podman_gw_gateway }}` (default `100.64.0.1`)
- Use `host.containers.internal` (resolves to the same address) or the literal gateway IP
- Avoids insecure `network_mode: host`
The default is configured in `/etc/containers/containers.conf` via the
`podman` role:
```ini
[network]
default_rootless_network_cmd = "pasta"
pasta_options = ["--map-host-loopback", "100.64.0.1"]
```
Note: `default_rootless_network_cmd` only affects `podman run`. `podman play
kube` defaults to a bridge network, which rootless users cannot create. Pod
services must pass the flag explicitly on the CLI:
```
ExecStart=/usr/bin/podman play kube --replace \
--network=pasta:--map-host-loopback={{ podman_gw_gateway }} myservice.yaml
```
### 4. Nginx Reverse Proxy Pattern
**Why:** Independent deployments, zero-downtime reloads
**Implementation:**
- Each service deploys its own vhost config to `{{ nginx_conf_dir }}/<service>.conf`
- Default `nginx_conf_dir: /etc/nginx/conf.d` (configurable in inventory)
- Services control exposure via `<service>_nginx_enabled` variable
- Nginx reloads gracefully when configs change
- **Always use `{{ nginx_conf_dir }}` variable, never hardcode paths**
### 5. OS Abstraction
**Why:** Support multiple distributions (Arch Linux, Debian/Ubuntu)
**Implementation:**
```
roles/postgres/vars/
├── archlinux.yml # Arch-specific (user: postgres, package: postgresql)
└── debian.yml # Debian-specific (user: postgres, package: postgresql)
```
Tasks load: `with_first_found: ["{{ ansible_facts['os_family'] }}.yml", "debian.yml"]`
## Creating a New Service Role
### 1. Directory Structure
```
roles/myservice/
├── defaults/main.yml # Variables
├── tasks/main.yml # Main tasks
├── handlers/main.yml # Handlers
├── templates/
│ ├── myservice.yaml.j2 # Kubernetes Pod spec (if containerized)
│ ├── myservice.service.j2 # systemd user unit (if containerized)
│ └── nginx-vhost.conf.j2 # If web-accessible
├── meta/main.yml # Dependencies
└── README.md # Documentation
```
### 2. Meta Dependencies
```yaml
dependencies:
- role: podman # If using containers
- role: postgres # If needs database
- role: redis # If needs cache
```
**Important:** Only include dependencies that are **always** required. Optional dependencies (like nginx for reverse proxy) should be added explicitly in playbooks, not in `meta/main.yml`.
### 3. Rootless Podman and User Systemd Services
**Architecture:** This is a single-user administrative server running rootless Podman. All containerized services:
- Run as `{{ ansible_user }}` (rootless Podman)
- Have files owned by `{{ ansible_user }}`
- Use systemd user services (not system services)
- Require lingering to start at boot without login
**Critical Implementation Details:**
1. **User Systemd Services Template:**
```jinja2
[Unit]
Description=My Service
[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
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
```
**Why `podman kube play --service-container=true`:** All containerized
services in this repo use Kubernetes Pod manifests deployed via
`podman kube play`. The `--service-container=true` flag spawns an extra
long-lived container whose lifetime mirrors the pod, so the systemd unit's
main PID stays alive and emits sd_notify. This is what makes
`Type=notify`, `Restart=on-failure`, and `systemctl --user --failed` work
correctly — without it, `kube play` exits 0 immediately and systemd stays
in `active (exited)` forever, even while containers inside the pod
crash-loop. The explicit `--network=pasta:...` flag is required because
`podman kube play` defaults to a bridge network, which rootless users
cannot create (see section 3).
**Do not use** `Type=oneshot` + `RemainAfterExit=true` for podman pods.
That pattern silently hides crash loops from systemd.
**Pod manifest must use `restartPolicy: Never` on every container.**
systemd is the single owner of the restart loop: a container exit takes the
pod down, which takes the service container down, which fails the unit,
which triggers `Restart=on-failure`. With `restartPolicy: Always` or
`OnFailure`, podman retries internally and systemd never sees the failure.
**Future direction:** see `roadmap/2026-05-29-quadlet.md` for the planned migration to
native Podman Quadlet units (`.kube` files), which removes most of this
boilerplate.
**Critical differences from system services:**
- `WantedBy=default.target` (NOT `multi-user.target`)
- No `network-online.target` dependency (doesn't exist in user systemd)
- User services start after the system is up, so network dependencies are implicit
2. **Service File Placement:**
```yaml
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service
ansible.builtin.template:
src: myservice.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/myservice.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
```
**Why getent + set_fact:** `ansible_env.HOME` is evaluated in the controller/initial context and may resolve to `/root` instead of the target user's home. Using `getent` to query `/etc/passwd` provides the correct home directory (field 4), and storing it in a fact makes subsequent references cleaner and more readable.
3. **Enable Lingering:**
```yaml
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
```
Lingering ensures the user's systemd instance starts at boot and persists when the user is not logged in.
4. **Service Management:**
```yaml
- name: Enable and start service (user scope)
ansible.builtin.command: "systemctl --user enable --now myservice.service"
become_user: "{{ ansible_user }}"
```
**Why `podman play kube`:** Kubernetes Pod manifests are portable and let multiple containers share a network namespace without defining a Compose network. The `--network=pasta:...` CLI flag overrides the default bridge (which rootless users cannot create) and inherits the same loopback mapping configured in `containers.conf`.
### 4. Password Validation Pattern (REQUIRED)
All roles requiring passwords **must** validate them at the start of tasks:
```yaml
- 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 for configuration instructions.
success_msg: "Password validation passed"
```
**Why this pattern:**
- Prevents accidental deployment with missing/weak passwords
- Fails fast with clear error message
- Directs users to defaults/main.yml for setup instructions
- Keeps error messages concise (target audience: minimal tech knowledge)
**In defaults/main.yml, passwords should be undefined:**
```yaml
# myservice_password: "" # Intentionally undefined - role will fail if not set
```
**Never use "changeme" defaults** - always fail if password not explicitly set.
## Best Practices
### Ansible Usage
**`become: true` is redundant** - All playbooks are run with `--ask-become-pass`, so every task already runs with elevated privileges. Only use `become_user` to switch to a specific user (e.g., `become_user: postgres`).
### Security
1. **Passwords**: Always use Ansible Vault in production
```bash
ansible-vault encrypt_string 'password' --name 'myservice_db_password'
```
2. **Bind addresses**: PostgreSQL and Redis bind to `127.0.0.1` only
3. **Database users**: Minimal privileges (NOSUPERUSER, NOCREATEDB, NOCREATEROLE)
4. **Nginx**: Only expose services that need external access
### Variable Naming
- Role-specific: `<role>_variable_name`
- Generic (cross-role): Add to `.ansible-lint` skip list
### File Permissions
- Config files: `0644`
- Secrets: `0640` or `0600`
- Directories: `0755`
- Data directories: `0750`
### Idempotency
- Use `creates:` with command/shell
- Use `changed_when: false` for read-only operations
- Use appropriate `when:` conditions
### Handlers
- Use `notify` instead of direct state changes
- Keep in `handlers/main.yml`
- Common: `Reload nginx`, `Restart PostgreSQL`, `Reload systemd`
+84 -9
View File
@@ -6,8 +6,52 @@ This project is designed for personal/familial scale maintenance, if you find th
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](<(https://galaxy.ansible.com)>) if there's community interest! 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](<(https://galaxy.ansible.com)>) if there's community interest!
## Architecture Overview
**Platform Support:** Arch Linux, Debian/Ubuntu
**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 |
## 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 ## Requirements
Ansible `>=2.15`
Base tools: Base tools:
```sh ```sh
@@ -33,10 +77,11 @@ If you have a password on your ssh key `--ask-pass` is recommended, `--ask-becom
```sh ```sh
ansible-playbook -i inventory/hosts.yml playbook.yml \ ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-pass \ --ask-pass \
--ask-become-pass --ask-become-pass \
--ask-vault-pass
``` ```
You can also unlock your key system wide to simplify your calls: You can also call you ssh agent to unlock your key prior to simplify your calls:
```sh ```sh
ssh-add ~/.ssh/my_key ssh-add ~/.ssh/my_key
@@ -45,18 +90,34 @@ ansible-playbook -i inventory/hosts.yml playbook.yml \
--ask-become-pass --ask-become-pass
``` ```
## Target devices configuration ## Bootstrapping a new host
Requirements: For fresh hosts (only `root` available, no admin user yet):
- sshd up and running
- public key copied:
```sh ```sh
ssh-copy-id -i ~/.ssh/id_rsa.pub username@remote_host ansible-playbook playbooks/bootstrap.yml -l <hostname> --ask-pass
``` ```
- python3 installed (`pacman -Syu python3`) 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:
```sh
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`):
```sh
ssh root@<hostname> passwd jambon
```
After that, run the host playbook normally:
```sh
ansible-playbook playbooks/<hostname>.yml --ask-become-pass
```
## Developping ## Developping
@@ -66,3 +127,17 @@ Linting:
ansible-lint ansible-lint
npx prettier --write . npx prettier --write .
``` ```
## 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:
```sh
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.
-1
View File
@@ -1,5 +1,4 @@
[defaults] [defaults]
interpreter_python = /usr/bin/python3
roles_path = ./roles roles_path = ./roles
inventory = inventory/hosts.yml inventory = inventory/hosts.yml
remote_tmp = /tmp/.ansible-${USER} remote_tmp = /tmp/.ansible-${USER}
@@ -15,6 +15,36 @@ network_interfaces:
type: ethernet type: ethernet
mac_address: 0a:3f:5b:1c:d2:e4 mac_address: 0a:3f:5b:1c:d2:e4
# Unbound DNS resolver configuration
# ----------------------------------
unbound_custom_lan_domain: "example.lan"
unbound_interfaces:
- { address: "192.168.1.2", comment: "lan0" }
- { address: "192.168.20.4", comment: "wg0" }
unbound_access_control:
- { subnet: "192.168.1.0/24", action: "allow", view: "lan", comment: "lan0" }
- { subnet: "192.168.20.0/27", action: "allow", view: "vpn", comment: "wg0" }
unbound_custom_lan_config_path: "{{ unbound_config_base_path }}/lan.conf"
unbound_custom_lan_records:
"server.example.lan":
v4: 192.168.1.2
aliases:
- "server"
# unbound VPN configuration
unbound_custom_vpn_config_path: "{{ unbound_config_base_path }}/vpn.conf"
unbound_custom_vpn_records:
"server.example.lan":
v4: 192.168.20.4
aliases:
- "server"
unbound_firewall_allowed_sources:
- { src: "192.168.1.0/24", comment: "DNS from LAN" }
- { src: "192.168.20.0/27", comment: "DNS from VPN" }
# NTP servers configuration # NTP servers configuration
# ------------------------- # -------------------------
ntp_pools: ntp_pools:
@@ -80,17 +110,19 @@ zfs_datasets:
mountpoint: /mnt/omer/movies mountpoint: /mnt/omer/movies
state: present state: present
# Wireguard "client" VPN configuration # Wireguard VPN configuration
# ------------------------------------ # ----------------------------
wireguard_address: 192.168.20.4/27 wireguard_tunnels:
wireguard_peers: - interface: wg0
- name: "Marge server" address: 192.168.20.4/27
public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9 dns: 192.168.20.1
allowed_ips: server_mode: false
- 192.168.20.1/32 peers:
endpoint: 192.168.1.56:51820 - name: "Marge server"
wireguard_dns: 192.168.20.1 public_key: fB6zC8oWpQxN4yR2sT1uA7vJ9kH3mG5eD0cLlI8bV6aF2dP3eXwZ1qY4rU7tO9
wireguard_server_mode: false allowed_ips:
- 192.168.20.1/32
endpoint: 192.168.1.56:51820
# NFS server configuration # NFS server configuration
# ------------------------ # ------------------------
@@ -115,10 +147,10 @@ nfs_bind_addresses:
# Podman configuration # Podman configuration
# -------------------- # --------------------
podman_external_networks: # Address inside containers that maps to the host's loopback (via pasta
- name: immich # --map-host-loopback). Containers reach host services bound to 127.0.0.1
subnet: 172.20.0.0/16 # by connecting to this address. Defined in roles/podman/defaults/main.yml.
gateway: 172.20.0.1 # podman_gw_gateway: 100.64.0.1
# PostgreSQL configuration # PostgreSQL configuration
# ------------------------ # ------------------------
@@ -12,3 +12,4 @@ all:
ansible_user: jgarcia ansible_user: jgarcia
ansible_become: true ansible_become: true
ansible_become_method: sudo ansible_become_method: sudo
ansible_python_interpreter: /usr/bin/python3
+73
View File
@@ -0,0 +1,73 @@
---
# Bootstrap a fresh host: create the admin user with sudo and SSH access.
# Run this before any other playbook, when only root access is available:
#
# ansible-playbook playbooks/bootstrap.yml -l somehost
#
# After this, run other playbooks normally.
- name: Bootstrap admin user
hosts: "{{ target | default('all') }}"
gather_facts: false
vars:
ansible_user: root
ansible_become: false
# bootstrap_user: jambon
# bootstrap_ssh_public_key: "ssh-ed25519 AAAA..."
tasks:
- name: Detect OS and install python3 + sudo
ansible.builtin.raw: |
if command -v pacman > /dev/null 2>&1; then
pacman -Sy --noconfirm python sudo
elif command -v apt-get > /dev/null 2>&1; then
apt-get update -qq && apt-get install -y python3 sudo
else
echo "Unsupported OS" && exit 1
fi
changed_when: true
- name: Gather facts
ansible.builtin.setup:
- name: Create admin user
ansible.builtin.user:
name: "{{ bootstrap_user }}"
groups: "{{ 'wheel' if ansible_facts['os_family'] == 'Archlinux' else 'sudo' }}"
append: true
shell: /bin/bash
create_home: true
state: present
- name: Allow sudo group to use sudo (Debian)
ansible.builtin.copy:
content: "%sudo ALL=(ALL:ALL) ALL\n"
dest: /etc/sudoers.d/sudo
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
when: ansible_facts['os_family'] == 'Debian'
- name: Allow wheel group to use sudo (Arch)
ansible.builtin.copy:
content: "%wheel ALL=(ALL:ALL) ALL\n"
dest: /etc/sudoers.d/wheel
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
when: ansible_facts['os_family'] == 'Archlinux'
- name: Create .ssh directory
ansible.builtin.file:
path: "/home/{{ bootstrap_user }}/.ssh"
state: directory
owner: "{{ bootstrap_user }}"
group: "{{ bootstrap_user }}"
mode: "0700"
- name: Add SSH authorized key
ansible.posix.authorized_key:
user: "{{ bootstrap_user }}"
key: "{{ bootstrap_ssh_public_key | default(lookup('file', '~/.ssh/id_ed25519.pub')) }}"
state: present
@@ -1,7 +1,7 @@
--- ---
- hosts: marge - name: Sample of a playbook
hosts: marge
become: true become: true
roles: roles:
- role: ntpd
- role: fail2ban - role: fail2ban
- role: unbound - role: unbound
+2
View File
@@ -0,0 +1,2 @@
---
requires_ansible: ">=2.15"
-16
View File
@@ -1,16 +0,0 @@
---
# - hosts: all
# become: true
# roles:
# - role: networking
# - role: sshd
# - role: disks
# - role: wireguard
# - role: zsh
# - role: archlinux
# - role: podman
- hosts: pinwheel
become: true
roles:
- role: sshd
+2
View File
@@ -1,6 +1,8 @@
--- ---
collections: collections:
- name: ansible.netcommon - name: ansible.netcommon
- name: ansible.posix
version: ">=2.2.0"
- name: community.general - name: community.general
- name: community.postgresql - name: community.postgresql
- name: containers.podman - name: containers.podman
+9 -3
View File
@@ -1,15 +1,21 @@
--- ---
- name: Configure locales - name: Configure locales
block: block:
- name: Activate locale
ansible.builtin.command:
cmd: localectl set-locale LANG={{ arch_locale }}
- name: Edit /etc/locale.gen - name: Edit /etc/locale.gen
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
dest: /etc/locale.gen dest: /etc/locale.gen
state: present state: present
regexp: "{{ arch_locale }}" regexp: "{{ arch_locale }}"
line: "{{ arch_locale }} UTF-8" line: "{{ arch_locale }} UTF-8"
register: locale_gen_changed
- name: Regenerate locales - name: Regenerate locales
ansible.builtin.command: ansible.builtin.command:
cmd: locale-gen cmd: locale-gen
when: locale_gen_changed is changed
changed_when: true
- name: Activate locale
ansible.builtin.command:
cmd: localectl set-locale LANG={{ arch_locale }}
changed_when: false
+4
View File
@@ -3,6 +3,10 @@
ansible.builtin.meta: end_play ansible.builtin.meta: end_play
when: ansible_facts['os_family'] != 'Archlinux' when: ansible_facts['os_family'] != 'Archlinux'
- name: Set hostname
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"
- name: Archlinux base setup - name: Archlinux base setup
ansible.builtin.include_tasks: "{{ item }}" ansible.builtin.include_tasks: "{{ item }}"
loop: loop:
+12 -5
View File
@@ -68,7 +68,7 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
paru_url: "{{ item.browser_download_url }}" paru_url: "{{ item.browser_download_url }}"
loop: "{{ paru_release.json.assets }}" loop: "{{ paru_release.json.assets }}"
when: "'os_arch.tar.zst' in item.name" when: "(os_arch + '.tar.zst') in item.name"
- name: Download - name: Download
ansible.builtin.get_url: ansible.builtin.get_url:
@@ -77,12 +77,19 @@
mode: "0644" mode: "0644"
- name: Extract paru - name: Extract paru
ansible.builtin.command: ansible.builtin.unarchive:
cmd: "tar -xf /tmp/paru-{{ os_arch }}.tar.zst paru -C /tmp" src: "/tmp/paru-{{ os_arch }}.tar.zst"
dest: /tmp
remote_src: true
extra_opts:
- paru
- name: Install paru binary - name: Install paru binary
ansible.builtin.command: ansible.builtin.copy:
cmd: "mv /tmp/paru /usr/bin/paru" src: /tmp/paru
dest: /usr/bin/paru
remote_src: true
mode: "0755"
- name: Ensure permissions - name: Ensure permissions
ansible.builtin.file: ansible.builtin.file:
+8 -2
View File
@@ -22,21 +22,27 @@
line: "%wheel ALL=(ALL) NOPASSWD: ALL" line: "%wheel ALL=(ALL) NOPASSWD: ALL"
validate: /usr/sbin/visudo -cf %s validate: /usr/sbin/visudo -cf %s
- name: Clean up stale yay sources dir
ansible.builtin.file:
path: "{{ yay_src_path }}"
state: absent
- name: Create yay sources dir - name: Create yay sources dir
ansible.builtin.file: ansible.builtin.file:
path: "{{ yay_src_path }}" path: "{{ yay_src_path }}"
state: directory state: directory
owner: "{{ ansible_user }}" owner: "{{ ansible_user }}"
mode: "0755"
- name: Clone git sources - name: Clone git sources
become: false become_user: "{{ ansible_user }}"
ansible.builtin.git: ansible.builtin.git:
repo: "{{ yay_git_repo }}" repo: "{{ yay_git_repo }}"
dest: "{{ yay_src_path }}" dest: "{{ yay_src_path }}"
# note: this only works because SUDOERS password prompt is disabled # note: this only works because SUDOERS password prompt is disabled
- name: Build and install - name: Build and install
become: false become_user: "{{ ansible_user }}"
ansible.builtin.command: ansible.builtin.command:
chdir: "{{ yay_src_path }}" chdir: "{{ yay_src_path }}"
cmd: "makepkg -si -f --noconfirm" cmd: "makepkg -si -f --noconfirm"
+29
View File
@@ -0,0 +1,29 @@
# dhcpd
ISC DHCP server role for Arch Linux and Debian/Ubuntu.
## Requirements
- `dhcpd_interface` must be defined in inventory
## Configuration
See [defaults/main.yml](defaults/main.yml) for all available variables.
## Example
```yaml
dhcpd_interface: "lan0"
dhcpd_subnet: "192.168.1.0"
dhcpd_range_start: "192.168.1.20"
dhcpd_range_end: "192.168.1.200"
dhcpd_gateway: "192.168.1.1"
dhcpd_dns_servers:
- "192.168.1.2"
dhcpd_domain_name: "home.lan"
dhcpd_reservations:
- hostname: printer
mac: "aa:bb:cc:dd:ee:ff"
ip: "192.168.1.10"
```
+27
View File
@@ -0,0 +1,27 @@
# Network configuration
dhcpd_subnet: "192.168.1.0"
dhcpd_netmask: "255.255.255.0"
dhcpd_range_start: "192.168.1.20"
dhcpd_range_end: "192.168.1.200"
dhcpd_gateway: "192.168.1.1"
dhcpd_dns_servers:
- "1.1.1.1"
# Lease times (in seconds)
dhcpd_default_lease_time: 86400 # 24 hours
dhcpd_max_lease_time: 172800 # 48 hours
# Interface to listen on (required)
# dhcpd_interface: "lan0"
# Domain name (optional)
# dhcpd_domain_name: "home.lan"
# Static reservations
# dhcpd_reservations:
# - hostname: printer
# mac: "aa:bb:cc:dd:ee:ff"
# ip: "192.168.1.10"
# - hostname: nas
# mac: "11:22:33:44:55:66"
# ip: "192.168.1.2"
+9
View File
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart dhcpd
ansible.builtin.systemd:
name: "{{ dhcpd_service }}"
state: restarted
+81
View File
@@ -0,0 +1,81 @@
---
- name: Validate required variables
ansible.builtin.assert:
that:
- dhcpd_interface is defined
- dhcpd_interface | length > 0
fail_msg: |
dhcpd_interface is required.
See roles/dhcpd/defaults/main.yml for configuration instructions.
success_msg: "Variable validation passed"
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] | lower }}.yml"
- "debian.yml"
- name: Install DHCP server
ansible.builtin.package:
name: "{{ dhcpd_package }}"
state: present
- name: Deploy DHCP server configuration
ansible.builtin.template:
src: dhcpd.conf.j2
dest: "{{ dhcpd_config_path }}"
owner: root
group: root
mode: "0644"
notify: Restart dhcpd
- name: Configure interface for DHCP server (Debian)
ansible.builtin.template:
src: isc-dhcp-server.j2
dest: "{{ dhcpd_defaults_path }}"
owner: root
group: root
mode: "0644"
when: ansible_facts['os_family'] | lower == 'debian'
notify: Restart dhcpd
- name: Deploy dhcpd4@ systemd template unit (Arch)
ansible.builtin.template:
src: dhcpd4@.service.j2
dest: /usr/lib/systemd/system/dhcpd4@.service
owner: root
group: root
mode: "0644"
when: ansible_facts['os_family'] == 'Archlinux'
notify:
- Reload systemd
- Restart dhcpd
- name: Disable generic dhcpd4.service (Arch)
ansible.builtin.systemd:
name: "{{ dhcpd_service_generic }}"
enabled: false
state: stopped
when:
- ansible_facts['os_family'] == 'Archlinux'
- dhcpd_service_generic is defined
failed_when: false
- name: Enable and start DHCP server
ansible.builtin.systemd:
name: "{{ dhcpd_service }}"
enabled: true
state: started
- name: Allow DHCP traffic on {{ dhcpd_interface }}
community.general.ufw:
rule: allow
port: "67"
proto: udp
direction: in
interface: "{{ dhcpd_interface }}"
comment: "DHCP on {{ dhcpd_interface }}"
retries: 5
delay: 2
register: ufw_dhcp_result
until: ufw_dhcp_result is succeeded
+28
View File
@@ -0,0 +1,28 @@
# {{ ansible_managed }}
# Global options
default-lease-time {{ dhcpd_default_lease_time }};
max-lease-time {{ dhcpd_max_lease_time }};
authoritative;
{% if dhcpd_domain_name is defined %}
option domain-name "{{ dhcpd_domain_name }}";
{% endif %}
option domain-name-servers {{ dhcpd_dns_servers | join(', ') }};
# Subnet configuration
subnet {{ dhcpd_subnet }} netmask {{ dhcpd_netmask }} {
range {{ dhcpd_range_start }} {{ dhcpd_range_end }};
option routers {{ dhcpd_gateway }};
}
# Static reservations
{% if dhcpd_reservations is defined %}
{% for host in dhcpd_reservations %}
host {{ host.hostname }} {
hardware ethernet {{ host.mac }};
fixed-address {{ host.ip }};
}
{% endfor %}
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
# {{ ansible_managed }}
[Unit]
Description=IPv4 DHCP server on %I
After=sys-subsystem-net-devices-%i.device network-online.target systemd-networkd-wait-online@%i.service
Wants=network-online.target systemd-networkd-wait-online@%i.service
BindsTo=sys-subsystem-net-devices-%i.device
[Service]
Type=forking
ExecStart=/usr/bin/dhcpd -4 -q -cf /etc/dhcpd.conf -pf /run/dhcpd4/dhcpd-%i.pid %I
RuntimeDirectory=dhcpd4
PIDFile=/run/dhcpd4/dhcpd-%i.pid
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# Defaults for isc-dhcp-server
INTERFACESv4="{{ dhcpd_interface }}"
INTERFACESv6=""
+4
View File
@@ -0,0 +1,4 @@
dhcpd_package: dhcp
dhcpd_service: "dhcpd4@{{ dhcpd_interface }}"
dhcpd_service_generic: dhcpd4
dhcpd_config_path: /etc/dhcpd.conf
+4
View File
@@ -0,0 +1,4 @@
dhcpd_package: isc-dhcp-server
dhcpd_service: isc-dhcp-server
dhcpd_config_path: /etc/dhcp/dhcpd.conf
dhcpd_defaults_path: /etc/default/isc-dhcp-server
+4
View File
@@ -0,0 +1,4 @@
---
- name: Systemd daemon reload
ansible.builtin.systemd:
daemon_reload: true
+1 -5
View File
@@ -23,11 +23,7 @@
group: root group: root
mode: "0644" mode: "0644"
register: timer_config register: timer_config
notify: Systemd daemon reload
- name: Systemd daemon reload
ansible.builtin.systemd:
daemon_reload: true
when: timer_config.changed
- name: Enable periodic trim - name: Enable periodic trim
ansible.builtin.systemd: ansible.builtin.systemd:
+4
View File
@@ -0,0 +1,4 @@
---
- name: Inform user to relogin
ansible.builtin.debug:
msg: "Please logout and login again to make sure the user is added to the docker group"
+1 -6
View File
@@ -35,9 +35,4 @@
name: "{{ ansible_user }}" name: "{{ ansible_user }}"
groups: docker groups: docker
append: true append: true
register: docker_group notify: Inform user to relogin
- name: Inform the user that user needs to logout and login again
ansible.builtin.debug:
msg: "Please logout and login again to make sure the user is added to the docker group"
when: docker_group.changed
+71
View File
@@ -0,0 +1,71 @@
# fdroid - F-Droid Custom APK Repository
Deploys an [F-Droid](https://f-droid.org/) repository server using [austozi/fdroidserver](https://github.com/austozi/docker-fdroidserver) to host custom APKs for family devices.
## Configuration
### Required Variables
Set in inventory or vault:
```yaml
fdroid_keystore_password: "your-secure-password-here" # Min 12 chars
```
### Optional Variables
See [defaults/main.yml](defaults/main.yml) for all configuration options.
Key settings:
```yaml
fdroid_version: "26.2.1"
fdroid_port: 8070
fdroid_repo_url: "https://apk.jokester.fr/repo"
fdroid_repo_name: "F-Droid Repository"
fdroid_repo_description: "Custom APK repository"
fdroid_update_interval: "12h"
# Nginx reverse proxy
fdroid_nginx_enabled: false
fdroid_nginx_hostname: apk.nas.local
```
## Usage
### Adding APKs
```bash
scp my-app.apk jokester@andromeda:/opt/podman/fdroid/data/repo/
```
The container automatically re-runs `fdroid update` every `fdroid_update_interval` (default: 12h) to regenerate the signed index.
To trigger an immediate update:
```bash
ssh jokester@andromeda "podman exec fdroid-server fdroid update -c"
```
### F-Droid Client Setup
On family phones, open F-Droid and add a new repository:
- **Repository URL:** `https://apk.jokester.fr/repo`
- Accept the fingerprint on first connection
### Keystore Backup
The signing keystore at `{{ podman_projects_dir }}/fdroid/data/keystore.p12` is critical. If lost, all clients must re-add the repository. Back it up.
## Architecture
- **Container**: `austozi/fdroidserver` (Apache + fdroidserver + Android build-tools)
- **Storage**: Persistent data directory for keystore, config, metadata, and APKs
- **Networking**: Localhost binding, nginx reverse proxy for HTTPS
- **Index updates**: Automatic on configurable interval
## Dependencies
- podman
- nginx (if `fdroid_nginx_enabled: true`)
+29
View File
@@ -0,0 +1,29 @@
---
# F-Droid repository version (austozi/fdroidserver image tag)
fdroid_version: "26.2.1"
# Container image
fdroid_image: austozi/fdroidserver
# Host port mapping
fdroid_port: 8070
# Data directory (keystore, config, metadata, repo APKs)
fdroid_data_dir: "{{ podman_projects_dir }}/fdroid/data"
# Repository metadata
fdroid_repo_url: "https://apk.mysite.fr/repo"
fdroid_repo_name: "F-Droid Repository"
fdroid_repo_description: "Custom APK repository"
fdroid_repo_icon: "fdroid.svg"
fdroid_repo_icon_url: "https://f-droid.org/assets/fdroid-logo-text_S0MUfk_FsnAYL7n2MQye-34IoSNm6QM6xYjDnMqkufo=.svg"
# How often the container re-runs 'fdroid update' to re-sign the index
fdroid_update_interval: "24h"
# Keystore password for signing the repository index
# fdroid_keystore_password: "" # Intentionally undefined - role will fail if not set
# Nginx reverse proxy configuration
fdroid_nginx_enabled: false
fdroid_nginx_hostname: apk.nas.local
+20
View File
@@ -0,0 +1,20 @@
---
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart fdroid
ansible.builtin.systemd:
name: fdroid.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+3
View File
@@ -0,0 +1,3 @@
---
dependencies:
- role: podman
+179
View File
@@ -0,0 +1,179 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- fdroid_keystore_password is defined
- fdroid_keystore_password | length >= 12
fail_msg: |
fdroid_keystore_password is required (min 12 chars).
See roles/fdroid/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create fdroid project directory
ansible.builtin.file:
path: "{{ podman_projects_dir | default('/opt/podman') }}/fdroid"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create fdroid data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
loop:
- "{{ fdroid_data_dir }}"
- "{{ fdroid_data_dir }}/repo"
- "{{ fdroid_data_dir }}/metadata"
- name: Create fdroid repo icons directory
ansible.builtin.file:
path: "{{ fdroid_data_dir }}/repo/icons"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Download fdroid repository icon
ansible.builtin.get_url:
url: "{{ fdroid_repo_icon_url }}"
dest: "{{ fdroid_data_dir }}/repo/icons/{{ fdroid_repo_icon }}"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
- name: Deploy fdroid repository configuration
ansible.builtin.template:
src: config.yml.j2
dest: "{{ fdroid_data_dir }}/config.yml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
notify: Restart fdroid
- name: Pull fdroid container image
ansible.builtin.command: "podman pull {{ fdroid_image }}:{{ fdroid_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for fdroid
ansible.builtin.template:
src: fdroid.yaml.j2
dest: "{{ podman_projects_dir | default('/opt/podman') }}/fdroid/fdroid.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart fdroid
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for fdroid
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create systemd service for fdroid (user scope)
ansible.builtin.template:
src: fdroid.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/fdroid.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Check if lingering is enabled for {{ ansible_user }}
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ ansible_user }}"
register: linger_file
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
changed_when: true
when:
- ansible_user != 'root'
- not linger_file.stat.exists
- name: Check if keystore already exists
ansible.builtin.stat:
path: "{{ fdroid_data_dir }}/keystore.p12"
register: fdroid_keystore
- name: Initialize fdroid repository (generate keystore and first index)
ansible.builtin.command:
argv:
- podman
- run
- --rm
- -v
- "{{ fdroid_data_dir }}:/fdroid"
- -e
- "FDROID_REPO_URL={{ fdroid_repo_url }}"
- -e
- "FDROID_REPO_NAME={{ fdroid_repo_name }}"
- -e
- "FDROID_REPO_DESCRIPTION={{ fdroid_repo_description }}"
- -e
- "FDROID_REPO_ICON={{ fdroid_repo_icon }}"
- "{{ fdroid_image }}:{{ fdroid_version }}"
- "fdroid update -c --create-key"
when: not fdroid_keystore.stat.exists
register: fdroid_init
changed_when: fdroid_init.rc == 0
become: false
become_user: "{{ ansible_user }}"
- name: Flush handlers before starting fdroid
ansible.builtin.meta: flush_handlers
- name: Enable and start fdroid service (user scope)
ansible.builtin.systemd:
name: fdroid.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Wait for fdroid to be ready
ansible.builtin.wait_for:
port: "{{ fdroid_port }}"
host: 127.0.0.1
timeout: 60
- name: Provision TLS certificate for fdroid
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ fdroid_nginx_hostname }}"
when: fdroid_nginx_enabled
- name: Deploy nginx vhost configuration for fdroid
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/fdroid.conf"
owner: root
group: root
mode: "0644"
when: fdroid_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for fdroid
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/fdroid.conf"
state: absent
when: not fdroid_nginx_enabled
notify: Reload nginx
+14
View File
@@ -0,0 +1,14 @@
# F-Droid repository configuration
# Managed by Ansible - DO NOT EDIT MANUALLY
repo_url: "{{ fdroid_repo_url }}"
repo_name: "{{ fdroid_repo_name }}"
repo_description: "{{ fdroid_repo_description }}"
repo_icon: "{{ fdroid_repo_icon }}"
# Keystore configuration (auto-generated on first 'fdroid update -c --create-key')
repo_keyalias: fdroid-repo-key
keystore: keystore.p12
keystorepass: "{{ fdroid_keystore_password }}"
keypass: "{{ fdroid_keystore_password }}"
keydname: "CN={{ fdroid_nginx_hostname }}"
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=F-Droid Repository Server
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/fdroid
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} fdroid.yaml
ExecStop=/usr/bin/podman kube down fdroid.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+53
View File
@@ -0,0 +1,53 @@
---
apiVersion: v1
kind: Pod
metadata:
name: fdroid
labels:
app: fdroid
spec:
containers:
- name: server
image: {{ fdroid_image }}:{{ fdroid_version }}
ports:
- containerPort: 80
hostPort: {{ fdroid_port }}
env:
- name: TZ
value: "Europe/Paris"
- name: FDROID_REPO_URL
value: "{{ fdroid_repo_url }}"
- name: FDROID_REPO_NAME
value: "{{ fdroid_repo_name }}"
- name: FDROID_REPO_DESCRIPTION
value: "{{ fdroid_repo_description }}"
- name: FDROID_REPO_ICON
value: "{{ fdroid_repo_icon }}"
- name: FDROID_UPDATE_INTERVAL
value: "{{ fdroid_update_interval }}"
command: ["bash", "-c"]
args: ["apache2ctl -D FOREGROUND & fdroid update -c && while true; do sleep {{ fdroid_update_interval }} && fdroid update; done"]
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: fdroid-data
mountPath: /fdroid
- name: fdroid-repo
mountPath: /var/www/html/repo
readOnly: true
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: fdroid-data
hostPath:
path: {{ fdroid_data_dir }}
type: Directory
- name: fdroid-repo
hostPath:
path: {{ fdroid_data_dir }}/repo
type: Directory
@@ -0,0 +1,56 @@
# F-Droid repository vhost with Let's Encrypt (Certbot)
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ fdroid_nginx_hostname }};
# Certbot webroot for ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ fdroid_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ fdroid_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ fdroid_nginx_hostname }}/privkey.pem;
# SSL configuration
ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_fdroid;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_fdroid;
{% else %}
access_log /var/log/nginx/{{ fdroid_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ fdroid_nginx_hostname }}_error.log;
{% endif %}
# Allow large APK uploads if ever needed
client_max_body_size 200M;
# Redirect root to /repo for F-Droid client compatibility
location = / {
return 302 /repo;
}
location / {
proxy_pass http://127.0.0.1:{{ fdroid_port }};
proxy_set_header Host $http_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;
}
}
+33
View File
@@ -0,0 +1,33 @@
---
# Gitea version
gitea_version: "latest"
# Network configuration
gitea_port: 3100
# Container image
gitea_image: gitea/gitea
# Data directory
gitea_data_dir: "{{ podman_projects_dir }}/gitea/data"
# Database configuration (PostgreSQL)
gitea_postgres_db_name: gitea
gitea_postgres_user: gitea
gitea_postgres_schema: gitea
# gitea_postgres_password: "" # Required - set in inventory
# Application configuration
gitea_app_name: "Gitea"
gitea_domain: git.nas.local
gitea_root_url: "https://{{ gitea_domain }}"
# Disable SSH (HTTPS only for Git operations)
gitea_disable_ssh: true
# Disable registration
gitea_disable_registration: false
# Nginx reverse proxy configuration
gitea_nginx_enabled: true
gitea_nginx_hostname: "{{ gitea_domain }}"
+24
View File
@@ -0,0 +1,24 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart gitea
ansible.builtin.systemd:
name: gitea.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+4
View File
@@ -0,0 +1,4 @@
---
dependencies:
- role: podman
- role: postgres
+158
View File
@@ -0,0 +1,158 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- gitea_postgres_password is defined
- gitea_postgres_password | length >= 12
fail_msg: |
gitea_postgres_password is required (min 12 chars).
See roles/gitea/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create PostgreSQL user for Gitea
community.postgresql.postgresql_user:
name: "{{ gitea_postgres_user }}"
password: "{{ gitea_postgres_password }}"
state: present
become: false
become_user: "{{ postgres_admin_user }}"
- name: Create PostgreSQL database for Gitea
community.postgresql.postgresql_db:
name: "{{ gitea_postgres_db_name }}"
owner: "{{ gitea_postgres_user }}"
state: present
become: false
become_user: "{{ postgres_admin_user }}"
- name: Grant all privileges on database to Gitea user
community.postgresql.postgresql_privs:
login_db: "{{ gitea_postgres_db_name }}"
roles: "{{ gitea_postgres_user }}"
type: database
privs: ALL
state: present
become: false
become_user: "{{ postgres_admin_user }}"
- name: Ensure Gitea user has no superuser privileges
community.postgresql.postgresql_user:
name: "{{ gitea_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL schema for Gitea
community.postgresql.postgresql_schema:
name: "{{ gitea_postgres_schema }}"
database: "{{ gitea_postgres_db_name }}"
owner: "{{ gitea_postgres_user }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant schema permissions to Gitea user
community.postgresql.postgresql_privs:
login_db: "{{ gitea_postgres_db_name }}"
roles: "{{ gitea_postgres_user }}"
type: schema
objs: "{{ gitea_postgres_schema }}"
privs: CREATE,USAGE
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Gitea project directory
ansible.builtin.file:
path: "{{ podman_projects_dir | default('/opt/podman') }}/gitea"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create Gitea data directory
ansible.builtin.file:
path: "{{ gitea_data_dir }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Pull Gitea container image
ansible.builtin.command: "podman pull {{ gitea_image }}:{{ gitea_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Gitea
ansible.builtin.template:
src: gitea.yaml.j2
dest: "{{ podman_projects_dir | default('/opt/podman') }}/gitea/gitea.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart gitea
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Gitea
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create systemd service for Gitea (user scope)
ansible.builtin.template:
src: gitea.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/gitea.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
- name: Enable and start Gitea service (user scope)
ansible.builtin.systemd:
name: gitea.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Gitea
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ gitea_nginx_hostname }}"
when: gitea_nginx_enabled
- name: Deploy nginx vhost configuration for Gitea
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/gitea.conf"
owner: root
group: root
mode: "0644"
when: gitea_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for Gitea
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/gitea.conf"
state: absent
when: not gitea_nginx_enabled
notify: Reload nginx
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Gitea Git Service
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} gitea.yaml
ExecStop=/usr/bin/podman kube down gitea.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+54
View File
@@ -0,0 +1,54 @@
---
apiVersion: v1
kind: Pod
metadata:
name: gitea
labels:
app: gitea
spec:
containers:
- name: server
image: {{ gitea_image }}:{{ gitea_version }}
ports:
- containerPort: {{ gitea_port }}
hostPort: {{ gitea_port }}
env:
- name: GITEA__database__DB_TYPE
value: postgres
- name: GITEA__database__HOST
value: {{ immich_postgres_host | default('127.0.0.1') }}
- name: GITEA__database__PORT
value: "5432"
- name: GITEA__database__NAME
value: "{{ gitea_postgres_db_name }}"
- name: GITEA__database__USER
value: "{{ gitea_postgres_user }}"
- name: GITEA__database__PASSWD
value: "{{ gitea_postgres_password }}"
- name: GITEA__server__DOMAIN
value: "{{ gitea_domain }}"
- name: GITEA__server__ROOT_URL
value: "{{ gitea_root_url }}"
- name: GITEA__server__HTTP_PORT
value: "{{ gitea_port }}"
- name: GITEA__server__DISABLE_SSH
value: "{{ 'true' if gitea_disable_ssh else 'false' }}"
- name: GITEA__service__DISABLE_REGISTRATION
value: "{{ 'true' if gitea_disable_registration else 'false' }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: gitea-data
mountPath: /data
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: gitea-data
hostPath:
path: {{ gitea_data_dir }}
type: Directory
View File
+55
View File
@@ -0,0 +1,55 @@
# Gitea vhost with Let's Encrypt (Certbot)
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ gitea_nginx_hostname }};
# Certbot webroot for ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ gitea_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ gitea_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ gitea_nginx_hostname }}/privkey.pem;
# SSL configuration
ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_gitea;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_gitea;
{% else %}
access_log /var/log/nginx/{{ gitea_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ gitea_nginx_hostname }}_error.log;
{% endif %}
# Increase client max body size for large Git pushes
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:{{ gitea_port }};
proxy_set_header Host $http_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;
# Required for Git LFS and large repository operations
proxy_buffering off;
proxy_request_buffering off;
}
}
+10 -380
View File
@@ -1,395 +1,31 @@
# Immich Role # Immich Role
This Ansible role deploys [Immich](https://immich.app/) - a high performance self-hosted photo and video management solution - using Podman with docker-compose files. This Ansible role deploys [Immich](https://immich.app/) - a high performance self-hosted photo and video management solution - using Podman with k8s files.
## Requirements
- Podman installed on the target system (handled by the `podman` role dependency)
- Podman compose support (`podman compose` command available)
- Sufficient disk space for photos/videos at the upload location
## Role Variables ## Role Variables
See `defaults/main.yml` for all available variables and their default values. See `defaults/main.yml` for all available variables and their default values.
### Key Configuration Requirements ### Required Passwords
#### Required Passwords
Both passwords must be set in your inventory (min 12 characters): Both passwords must be set in your inventory (min 12 characters):
- `immich_postgres_password` - PostgreSQL database password - `immich_postgres_password` - PostgreSQL database password
- `immich_valkey_password` - Valkey/Redis password - `immich_valkey_password` - Valkey/Redis password
#### Valkey ACL Configuration ## External Libraries
**Important:** Immich requires a dedicated Valkey ACL user with specific permissions. This role provides the ACL configuration, but you must register it with the Valkey role. Mount host paths read-only into the server container via `immich_external_libraries`,
then add the in-container `mount_path` in the Immich UI
**Required Setup in Inventory:** (Administration → External Libraries). The `{{ ansible_user }}` running the rootless
pod must have read access on the host path.
Add the Immich user to your `valkey_acl_users` list in your inventory or host_vars:
```yaml
# inventory/host_vars/yourserver.yml or group_vars/all.yml
valkey_acl_users:
- username: immich
password: "{{ immich_valkey_password }}"
keypattern: "immich_bull* immich_channel*"
commands: "&* -@dangerous +@read +@write +@pubsub +select +auth +ping +info +eval +evalsha"
```
**ACL Breakdown:**
- `keypattern: "immich_bull* immich_channel*"` - Restricts access to BullMQ keys used by Immich
- `&*` - Allow all pub/sub channels (required for BullMQ job queues)
- `-@dangerous` - Deny dangerous commands (FLUSHDB, FLUSHALL, KEYS, etc.)
- `+@read +@write` - Allow read/write command groups
- `+@pubsub` - Allow pub/sub commands (SUBSCRIBE, PUBLISH, etc.)
- `+select` - Allow SELECT command (database switching)
- `+auth +ping +info` - Connection management commands
- `+eval +evalsha` - Lua scripting (required by BullMQ for atomic operations)
**Based on:** [Immich GitHub Discussion #19727](https://github.com/immich-app/immich/discussions/19727#discussioncomment-13668749)
**Security Benefits:**
- Immich cannot access keys from other services
- Cannot execute admin commands (FLUSHDB, CONFIG, etc.)
- Cannot view all keys (KEYS command denied)
- Defense-in-depth with ACL + key patterns + database numbers
#### External Network Configuration
Immich requires a dedicated external network to be defined in your inventory. Add this to your `host_vars` or `group_vars`:
```yaml
podman_external_networks:
- name: immich
subnet: 172.20.0.0/16
gateway: 172.20.0.1
```
**How it works:**
1. Define the Immich network in `podman_external_networks` list in your inventory
2. The `podman` role (a dependency) creates the external network before Immich deployment
3. The Immich docker-compose file references this external network
4. The network persists across container restarts and compose stack rebuilds
## Dependencies
This role depends on:
- `podman` - Container runtime
- `postgres` - PostgreSQL database
- `valkey` - Redis-compatible cache (formerly Redis)
**Note:** The Valkey role must be configured with the Immich ACL user (see Valkey Configuration section above) before running this role.
## Example Playbook
```yaml
---
- hosts: servers
become: true
roles:
- role: podman
- role: immich
vars:
immich_postgres_password: "your-secure-postgres-password"
immich_valkey_password: "your-secure-valkey-password"
immich_upload_location: /mnt/storage/immich/upload
immich_timezone: America/New_York
```
**Complete Example with Valkey ACL:**
In `inventory/host_vars/yourserver.yml`:
```yaml
# Podman external networks
podman_external_networks:
- name: immich
subnet: 172.20.0.0/16
gateway: 172.20.0.1
# Valkey admin password
valkey_admin_password: "your-valkey-admin-password"
# Valkey ACL users - register all service users here
valkey_acl_users:
- username: immich
password: "{{ immich_valkey_password }}"
keypattern: "immich_bull* immich_channel*"
commands: "&* -@dangerous +@read +@write +@pubsub +select +auth +ping +info +eval +evalsha"
# Add other services here as needed
# Immich passwords
immich_postgres_password: "your-secure-postgres-password"
immich_valkey_password: "your-secure-valkey-password"
```
In your playbook:
```yaml
---
- hosts: servers
become: true
roles:
- role: valkey # Must run first to create ACL users
- role: postgres
- role: podman
- role: immich
```
## Architecture
The role deploys Immich using Podman containers that connect to shared system services:
**Immich Containers:**
1. **immich-server** - Main application server (exposed on configured port)
2. **immich-machine-learning** - ML service for facial recognition and object detection
**Shared System Services:**
3. **PostgreSQL** - Database with vector extensions (from `postgres` role)
4. **Valkey** - Redis-compatible cache (from `valkey` role)
### Container Networking
Both Immich containers run on a **dedicated external Podman network** with its own CIDR block. The network is created by the `podman` role as an external network, referenced in the compose file:
```yaml
networks:
immich:
external: true
name: immich
```
The actual network configuration (subnet: `172.20.0.0/16`, gateway: `172.20.0.1`) is handled by the podman role based on the `immich_network_*` variables.
This provides:
- **Network isolation**: Separate subnet (defined in inventory, e.g., `172.20.0.0/16`) from other containers
- **Network persistence**: Network survives compose stack rebuilds and container recreation
- **Named bridge**: Explicit interface naming for the network
- **Container-to-container communication**: The server reaches the ML container via service name (`immich-machine-learning:3003`) using Docker/Podman internal DNS
- **Container-to-host communication**: Both containers can reach PostgreSQL and Valkey on the host via `host.containers.internal:{{ podman_subnet_gateway }}`
**Key Points:**
- The network must be defined in your inventory via `podman_external_networks`
- The network is created by the `podman` role before Immich deployment (via role dependency)
- The Immich network has its own gateway (e.g., `172.20.0.1` as defined in inventory)
- `extra_hosts` maps `host.containers.internal` to the **Podman default bridge gateway** (e.g., `10.88.0.1`), not the Immich network gateway
- This allows containers to route to the host machine for PostgreSQL/Valkey access
**Checking the network:**
```bash
# List all Podman networks
podman network ls
# Inspect the Immich network
podman network inspect immich
```
### Data Isolation
The role implements proper data isolation for both database backends:
- **PostgreSQL**: Immich gets its own database (`immich`) and dedicated user (`immich`) with restricted privileges (NOSUPERUSER, NOCREATEDB, NOCREATEROLE)
- **Valkey**: Immich uses a dedicated ACL user (`immich`) with:
- Dedicated password (independent from `valkey_admin_password`)
- Key pattern restriction (`immich_bull*` and `immich_channel*` only)
- Command restrictions (no admin/dangerous operations like FLUSHDB, CONFIG)
- Database number isolation (uses DB 0 by default, configurable)
- Pub/sub channel access for BullMQ job queues
**Security Benefits:**
- Each service has unique credentials
- Compromised service cannot access other services' data
- Cannot accidentally delete all data (FLUSHDB/FLUSHALL denied)
- Cannot view keys from other services (KEYS command denied)
- Defense-in-depth: ACL + key patterns + command restrictions + database numbers
The compose file is deployed to `{{ podman_projects_dir }}/immich/docker-compose.yml` and managed via a systemd service.
## Nginx Reverse Proxy with ACME/Let's Encrypt
The role includes an Nginx vhost template with native ACME support for automatic HTTPS certificate management.
**Prerequisites:**
1. Nginx role deployed with `acme_email` configured
2. Port 80/443 accessible from internet (for ACME HTTP-01 challenge)
3. DNS pointing to your server
**Configuration:**
```yaml
# Enable Nginx reverse proxy
immich_nginx_enabled: true
immich_nginx_hostname: "blog.hello.com"
# In nginx role configuration (host_vars or group_vars)
acme_email: "admin@carabosse.cloud"
```
**What it does:**
- Deploys HTTPS vhost with automatic Let's Encrypt certificate
- HTTP → HTTPS redirect
- Proxies to Immich container on localhost
- Handles WebSocket upgrades for live photos
- Large file upload support (50GB max)
**ACME automatic features:**
- Certificate issuance on first deployment
- Automatic renewal
- HTTP-01 challenge handling
## Post-Installation
After deployment:
1. Access Immich at:
- **With Nginx enabled**: `https://{{ immich_nginx_hostname }}`
- **Without Nginx**: `http://<host-ip>:{{ immich_port }}`
2. Create an admin account on first login
3. Configure mobile/desktop apps to point to your server
## Management
The role creates a systemd service for managing the compose stack:
```bash
# Check status
systemctl status immich
# Stop Immich
systemctl stop immich
# Start Immich
systemctl start immich
# Restart Immich
systemctl restart immich
# View logs for all containers
cd /opt/podman/immich && podman compose logs -f
# View logs for specific service
cd /opt/podman/immich && podman compose logs -f immich-server
```
### Manual Management
You can also manage containers directly with podman compose:
```bash
cd /opt/podman/immich
# Start services
podman compose up -d
# Stop services
podman compose down
# Pull latest images
podman compose pull
# Recreate containers
podman compose up -d --force-recreate
```
## Updating Immich
To update to a newer version:
1. Update the `immich_version` variable in your playbook or inventory
2. Re-run the Ansible playbook
3. The systemd service will restart with the new version
Or manually:
```bash
cd /opt/podman/immich
podman compose pull
systemctl restart immich
```
## Storage
- **Upload location**: Stores all photos, videos, and thumbnails
- **Database location**: PostgreSQL data (not suitable for network shares)
- **Model cache**: ML models for facial recognition
Ensure adequate disk space and regular backups of these directories.
## Files Deployed
- `{{ podman_projects_dir }}/immich/docker-compose.yml` - Compose definition
- `/etc/systemd/system/immich.service` - Systemd service unit
## Security Considerations
- **Set strong passwords** for both `immich_postgres_password` and `immich_valkey_password` (min 12 chars)
- **Use Ansible Vault** to encrypt passwords in production:
```bash
ansible-vault encrypt_string 'your-password' --name 'immich_postgres_password'
ansible-vault encrypt_string 'your-password' --name 'immich_valkey_password'
```
- **Configure Valkey ACL** properly (see Valkey Configuration section) - do not use `+@all`
- Consider using a reverse proxy (nginx/traefik) for HTTPS
- Restrict access via firewall rules if needed
- Keep Immich updated by changing `immich_version` and redeploying
## Troubleshooting ## Troubleshooting
### Check service status
```bash
systemctl status immich
```
### View compose file
```bash
cat /opt/podman/immich/docker-compose.yml
```
### Check container status
```bash
cd /opt/podman/immich
podman compose ps
```
### View logs
```bash
cd /opt/podman/immich
podman compose logs
```
### Valkey ACL Issues ### Valkey ACL Issues
**Error: "NOPERM No permissions to access a channel"**
- The Valkey ACL is missing channel permissions
- Ensure `&*` or `+allchannels` is in the ACL commands
- Verify ACL is properly loaded: `valkey-cli ACL LIST`
**Error: "NOAUTH Authentication required"**
- Check `immich_valkey_password` is set correctly
- Verify the password matches in both inventory ACL config and immich vars
**Error: "WRONGPASS invalid username-password pair"**
- Ensure the Immich user is registered in `valkey_acl_users`
- Check the Valkey ACL file was deployed: `cat /etc/valkey/users.acl`
- Restart Valkey to reload ACL: `systemctl restart valkey`
**Verify Valkey ACL Configuration:**
```bash
# Connect as admin
valkey-cli
AUTH default <valkey_admin_password>
# List all ACL users
ACL LIST
# Check specific user
ACL GETUSER immich
# Monitor commands (useful for debugging permissions)
MONITOR
```
**Test Immich user credentials:** **Test Immich user credentials:**
```bash ```bash
valkey-cli valkey-cli
AUTH immich <immich_valkey_password> AUTH immich <immich_valkey_password>
@@ -402,10 +38,4 @@ FLUSHDB
# Should return: (error) NOPERM # Should return: (error) NOPERM
``` ```
## License **Going further:** [Immich GitHub Discussion #19727](https://github.com/immich-app/immich/discussions/19727#discussioncomment-13668749)
MIT
## Author Information
Created for deploying Immich on NAS systems using Podman and docker-compose.
+11 -10
View File
@@ -5,17 +5,26 @@ immich_version: release
# Storage location (@see https://docs.immich.app/install/environment-variables/) # Storage location (@see https://docs.immich.app/install/environment-variables/)
immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload" immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload"
# External libraries (read-only host paths exposed to the server container)
# Use the in-container `mount_path` when registering the library in the Immich UI.
# Example:
# immich_external_libraries:
# - name: clement-photos
# host_path: /mnt/andromeda/clement-photos
# mount_path: /mnt/external/clement-photos
immich_external_libraries: []
# PostgreSQL configuration (REQUIRED password - must be set explicitly) # PostgreSQL configuration (REQUIRED password - must be set explicitly)
immich_postgres_db_name: immich immich_postgres_db_name: immich
immich_postgres_user: immich immich_postgres_user: immich
# immich_postgres_password: "" # Intentionally undefined - role will fail if not set # immich_postgres_password: "" # Intentionally undefined - role will fail if not set
immich_postgres_host: postgres.local # immich_postgres_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host postgres)
immich_postgres_port: 5432 immich_postgres_port: 5432
# Valkey configuration (REQUIRED password - must be set explicitly) # Valkey configuration (REQUIRED password - must be set explicitly)
immich_valkey_user: immich immich_valkey_user: immich
# immich_valkey_password: "" # Intentionally undefined - role will fail if not set # immich_valkey_password: "" # Intentionally undefined - role will fail if not set
immich_valkey_host: valkey.local # immich_valkey_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host valkey)
immich_valkey_port: 6379 immich_valkey_port: 6379
immich_valkey_db: 0 # Dedicated database number for isolation (0-15) immich_valkey_db: 0 # Dedicated database number for isolation (0-15)
@@ -37,14 +46,6 @@ immich_valkey_acl:
# Network configuration # Network configuration
immich_port: 2283 immich_port: 2283
# External network configuration
# Define in inventory via podman_external_networks list
# Example:
# podman_external_networks:
# - name: immich
# subnet: 172.20.0.0/16
# gateway: 172.20.0.1
# Container images # Container images
immich_server_image: ghcr.io/immich-app/immich-server immich_server_image: ghcr.io/immich-app/immich-server
immich_ml_image: ghcr.io/immich-app/immich-machine-learning immich_ml_image: ghcr.io/immich-app/immich-machine-learning
+11 -2
View File
@@ -3,11 +3,20 @@
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart Immich - name: Restart Immich
ansible.builtin.systemd: ansible.builtin.systemd:
name: immich name: immich.service
state: restarted state: restarted
daemon_reload: true scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx - name: Reload nginx
ansible.builtin.systemd: ansible.builtin.systemd:
+77 -20
View File
@@ -16,14 +16,16 @@
name: "{{ immich_postgres_db_name }}" name: "{{ immich_postgres_db_name }}"
owner: "{{ immich_postgres_user }}" owner: "{{ immich_postgres_user }}"
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL user for Immich - name: Create PostgreSQL user for Immich
community.postgresql.postgresql_user: community.postgresql.postgresql_user:
name: "{{ immich_postgres_user }}" name: "{{ immich_postgres_user }}"
password: "{{ immich_postgres_password }}" password: "{{ immich_postgres_password }}"
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant all privileges on database to Immich user - name: Grant all privileges on database to Immich user
community.postgresql.postgresql_privs: community.postgresql.postgresql_privs:
@@ -32,26 +34,41 @@
type: database type: database
privs: ALL privs: ALL
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Ensure Immich user has no superuser privileges - name: Ensure Immich user has no superuser privileges
community.postgresql.postgresql_user: community.postgresql.postgresql_user:
name: "{{ immich_postgres_user }}" name: "{{ immich_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Enable required PostgreSQL extensions in Immich database - name: Enable required PostgreSQL extensions in Immich database
community.postgresql.postgresql_ext: community.postgresql.postgresql_ext:
name: "{{ item }}" name: "{{ item }}"
login_db: "{{ immich_postgres_db_name }}" login_db: "{{ immich_postgres_db_name }}"
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
loop: loop:
- cube - cube
- earthdistance - earthdistance
- vector - vector
- name: Update PostgreSQL extensions to latest available version
community.postgresql.postgresql_query:
login_db: "{{ immich_postgres_db_name }}"
query: "ALTER EXTENSION {{ item }} UPDATE"
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
loop:
- cube
- earthdistance
- vector
changed_when: false
- name: Grant schema permissions to Immich user - name: Grant schema permissions to Immich user
community.postgresql.postgresql_privs: community.postgresql.postgresql_privs:
login_db: "{{ immich_postgres_db_name }}" login_db: "{{ immich_postgres_db_name }}"
@@ -60,11 +77,12 @@
objs: public objs: public
privs: CREATE,USAGE privs: CREATE,USAGE
state: present state: present
become_user: "{{ postgres_admin_user }}" become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Immich project directory - name: Create Immich project directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ podman_projects_dir }}/immich" path: "{{ podman_projects_dir | default('/opt/podman') }}/immich"
state: directory state: directory
owner: "{{ ansible_user }}" owner: "{{ ansible_user }}"
group: "{{ ansible_user }}" group: "{{ ansible_user }}"
@@ -80,35 +98,74 @@
loop: loop:
- "{{ immich_upload_location }}" - "{{ immich_upload_location }}"
- name: Deploy docker-compose.yml for Immich - name: Pull Immich container images
ansible.builtin.command: "podman pull {{ item }}"
loop:
- "{{ immich_server_image }}:{{ immich_version }}"
- "{{ immich_ml_image }}:{{ immich_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Immich
ansible.builtin.template: ansible.builtin.template:
src: docker-compose.yml.j2 src: immich.yaml.j2
dest: "{{ podman_projects_dir }}/immich/docker-compose.yml" dest: "{{ podman_projects_dir | default('/opt/podman') }}/immich/immich.yaml"
owner: "{{ ansible_user }}" owner: "{{ ansible_user }}"
group: "{{ ansible_user }}" group: "{{ ansible_user }}"
mode: "0644" mode: "0644"
notify: Restart Immich notify: Restart Immich
- name: Create systemd service for Immich - name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Immich
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create systemd service for Immich (user scope)
ansible.builtin.template: ansible.builtin.template:
src: immich.service.j2 src: immich.service.j2
dest: /etc/systemd/system/immich.service dest: "{{ user_home_dir }}/.config/systemd/user/immich.service"
owner: root owner: "{{ ansible_user }}"
group: root group: "{{ ansible_user }}"
mode: "0644" mode: "0644"
notify: Reload systemd notify: Reload systemd user
- name: Enable and start Immich service - name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
- name: Enable and start Immich service (user scope)
ansible.builtin.systemd: ansible.builtin.systemd:
name: immich name: immich.service
enabled: true enabled: true
state: started state: started
daemon_reload: true scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Immich
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ immich_nginx_hostname }}"
when: immich_nginx_enabled
- name: Deploy nginx vhost configuration for Immich - name: Deploy nginx vhost configuration for Immich
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
dest: /etc/nginx/conf.d/immich.conf dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/immich.conf"
owner: root owner: root
group: root group: root
mode: "0644" mode: "0644"
@@ -117,7 +174,7 @@
- name: Remove nginx vhost configuration for Immich - name: Remove nginx vhost configuration for Immich
ansible.builtin.file: ansible.builtin.file:
path: /etc/nginx/conf.d/immich.conf path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/immich.conf"
state: absent state: absent
when: not immich_nginx_enabled when: not immich_nginx_enabled
notify: Reload nginx notify: Reload nginx
@@ -1,62 +0,0 @@
---
services:
immich-server:
container_name: immich_server
image: {{ immich_server_image }}:{{ immich_version }}
networks:
- databases
- immich
extra_hosts:
- "{{ immich_postgres_host }}:{{ podman_subnet_gateway }}"
- "{{ immich_valkey_host }}:{{ podman_subnet_gateway }}"
volumes:
- /etc/localtime:/etc/localtime:ro
- {{ immich_upload_location }}:/data:rw,Z
environment:
DB_HOSTNAME: {{ immich_postgres_host }}
DB_PORT: {{ immich_postgres_port }}
DB_USERNAME: {{ immich_postgres_user }}
DB_PASSWORD: {{ immich_postgres_password }}
DB_DATABASE_NAME: {{ immich_postgres_db_name }}
REDIS_HOSTNAME: {{ immich_valkey_host }}
REDIS_PORT: {{ immich_valkey_port }}
REDIS_USERNAME: {{ immich_valkey_user }}
REDIS_PASSWORD: {{ immich_valkey_password }}
REDIS_DBINDEX: {{ immich_valkey_db }}
IMMICH_MACHINE_LEARNING_URL: http://immich-machine-learning:3003
UPLOAD_LOCATION: {{ immich_upload_location }}
TZ: {{ immich_timezone }}
ports:
- "{{ immich_port }}:2283"
restart: always
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:2283/api/server/ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
immich-machine-learning:
container_name: immich_machine_learning
image: {{ immich_ml_image }}:{{ immich_version }}
networks:
- immich
volumes:
- model-cache:/cache
restart: always
healthcheck:
test: ["CMD", "python", "/usr/src/healthcheck.py"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
databases:
name: podman
external: true
immich:
driver: bridge
volumes:
model-cache:
+7 -8
View File
@@ -1,16 +1,15 @@
[Unit] [Unit]
Description=Immich Media Server Description=Immich Media Server
Requires=network-online.target
After=network-online.target
[Service] [Service]
Type=oneshot Type=notify
RemainAfterExit=true NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/immich WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
ExecStart=/usr/bin/podman compose up -d ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} immich.yaml
ExecStop=/usr/bin/podman compose down ExecStop=/usr/bin/podman kube down immich.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=180
[Install] [Install]
WantedBy=multi-user.target WantedBy=default.target
+110
View File
@@ -0,0 +1,110 @@
---
apiVersion: v1
kind: Pod
metadata:
name: immich
labels:
app: immich
spec:
containers:
- name: server
image: {{ immich_server_image }}:{{ immich_version }}
ports:
- containerPort: 2283
hostPort: {{ immich_port }}
env:
- name: DB_HOSTNAME
value: "{{ immich_postgres_host }}"
- name: DB_PORT
value: "{{ immich_postgres_port }}"
- name: DB_USERNAME
value: "{{ immich_postgres_user }}"
- name: DB_PASSWORD
value: "{{ immich_postgres_password }}"
- name: DB_DATABASE_NAME
value: "{{ immich_postgres_db_name }}"
- name: REDIS_HOSTNAME
value: "{{ immich_valkey_host }}"
- name: REDIS_PORT
value: "{{ immich_valkey_port }}"
- name: REDIS_USERNAME
value: "{{ immich_valkey_user }}"
- name: REDIS_PASSWORD
value: "{{ immich_valkey_password }}"
- name: REDIS_DBINDEX
value: "{{ immich_valkey_db }}"
- name: IMMICH_MACHINE_LEARNING_URL
value: http://localhost:3003
- name: UPLOAD_LOCATION
value: /data
- name: TZ
value: "{{ immich_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: immich-data
mountPath: /data
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
mountPath: {{ lib.mount_path }}
readOnly: true
{% endfor %}
livenessProbe:
httpGet:
path: /api/server/ping
port: 2283
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
- name: machine-learning
image: {{ immich_ml_image }}:{{ immich_version }}
env:
- name: TZ
value: "{{ immich_timezone }}"
volumeMounts:
- name: model-cache
mountPath: /cache
livenessProbe:
exec:
command:
- python
- /usr/src/healthcheck.py
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: immich-data
hostPath:
path: {{ immich_upload_location }}
type: Directory
{% for lib in immich_external_libraries %}
- name: ext-{{ lib.name }}
hostPath:
path: {{ lib.host_path }}
type: Directory
{% endfor %}
- name: model-cache
persistentVolumeClaim:
claimName: immich-model-cache
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-model-cache
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+18 -4
View File
@@ -3,6 +3,7 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Certbot webroot for ACME challenges # Certbot webroot for ACME challenges
@@ -18,6 +19,8 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ immich_nginx_hostname }}; server_name {{ immich_nginx_hostname }};
# Let's Encrypt certificates (managed by Certbot) # Let's Encrypt certificates (managed by Certbot)
@@ -25,10 +28,10 @@ server {
ssl_certificate_key /etc/letsencrypt/live/{{ immich_nginx_hostname }}/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/{{ immich_nginx_hostname }}/privkey.pem;
# SSL configuration # SSL configuration
ssl_protocols {{ nginx_ssl_protocols }}; ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers {{ 'on' if nginx_ssl_prefer_server_ciphers else 'off' }}; ssl_prefer_server_ciphers on;
{% if nginx_log_backend == 'journald' %} {% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich; access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich; error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_immich;
{% else %} {% else %}
@@ -38,6 +41,12 @@ server {
client_max_body_size 50000M; client_max_body_size 50000M;
# Timeouts for slow mobile uploads (client <-> nginx leg)
client_body_timeout 600s;
client_header_timeout 600s;
send_timeout 600s;
keepalive_timeout 600s;
location / { location / {
proxy_pass http://127.0.0.1:{{ immich_port }}; proxy_pass http://127.0.0.1:{{ immich_port }};
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
@@ -50,7 +59,12 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
# Timeouts for large file uploads # Stream uploads directly to backend instead of buffering full body on disk
proxy_request_buffering off;
proxy_buffering off;
# Timeouts for large file uploads (nginx <-> immich leg)
proxy_connect_timeout 600s;
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_send_timeout 600s; proxy_send_timeout 600s;
} }
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Matrix Synapse + Element Web
Wants=network-online.target
After=network-online.target
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/matrix
ExecStart=/usr/bin/podman kube play --replace --service-container=true matrix.yaml
ExecStop=/usr/bin/podman kube down matrix.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+63
View File
@@ -0,0 +1,63 @@
---
apiVersion: v1
kind: Pod
metadata:
name: matrix
labels:
app: matrix
spec:
containers:
- name: synapse
image: {{ synapse_image }}:{{ synapse_version }}
ports:
- containerPort: 8008
hostPort: {{ synapse_port }}
{% if synapse_enable_federation %}
- containerPort: 8448
hostPort: {{ synapse_federation_port }}
{% endif %}
env:
- name: SYNAPSE_CONFIG_PATH
value: /data/homeserver.yaml
- name: TZ
value: "{{ matrix_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
- name: synapse-data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
- name: element
image: {{ element_image }}:{{ element_version }}
ports:
- containerPort: 80
hostPort: 8080
volumeMounts:
- name: element-config
mountPath: /app/config.json
subPath: config.json
restartPolicy: Never
volumes:
- name: localtime
hostPath:
path: /etc/localtime
type: File
- name: synapse-data
hostPath:
path: {{ synapse_data_dir }}
type: Directory
- name: element-config
hostPath:
path: {{ element_data_dir }}
type: Directory
+20
View File
@@ -0,0 +1,20 @@
# Metabase
Business intelligence and analytics. Defaults: [`defaults/main.yml`](defaults/main.yml).
## Requirements
- `podman` role
- `postgres` role
- `nginx` role (optional, for public access)
## Usage
Set in inventory:
```yaml
metabase_postgres_password: "strongpassword"
metabase_postgres_host: "{{ podman_gw_gateway }}"
metabase_nginx_enabled: true
metabase_nginx_hostname: metabase.example.com
```
+16
View File
@@ -0,0 +1,16 @@
---
metabase_version: latest
metabase_image: metabase/metabase
metabase_port: 3000
metabase_postgres_db_name: metabase
metabase_postgres_user: metabase
# metabase_postgres_password: "" # Intentionally undefined - role will fail if not set
# metabase_postgres_host: "" # Must be set in inventory (e.g. "{{ podman_gw_gateway }}")
metabase_postgres_port: 5432
metabase_timezone: UTC
metabase_nginx_enabled: false
metabase_nginx_hostname: metabase.nas.local
+20
View File
@@ -0,0 +1,20 @@
---
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart Metabase
ansible.builtin.systemd:
name: metabase.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+4
View File
@@ -0,0 +1,4 @@
---
dependencies:
- role: podman
- role: postgres
+130
View File
@@ -0,0 +1,130 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- metabase_postgres_password is defined
- metabase_postgres_password | length >= 12
fail_msg: |
metabase_postgres_password is required (min 12 chars).
See roles/metabase/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create PostgreSQL database for Metabase
community.postgresql.postgresql_db:
name: "{{ metabase_postgres_db_name }}"
owner: "{{ metabase_postgres_user }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create PostgreSQL user for Metabase
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
password: "{{ metabase_postgres_password }}"
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Grant all privileges on database to Metabase user
community.postgresql.postgresql_privs:
login_db: "{{ metabase_postgres_db_name }}"
roles: "{{ metabase_postgres_user }}"
type: database
privs: ALL
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Ensure Metabase user has no superuser privileges
community.postgresql.postgresql_user:
name: "{{ metabase_postgres_user }}"
role_attr_flags: NOSUPERUSER,NOCREATEDB,NOCREATEROLE
state: present
become: false
become_user: "{{ postgres_admin_user | default('postgres') }}"
- name: Create Metabase project directory
ansible.builtin.file:
path: "{{ podman_projects_dir }}/metabase"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Pull Metabase container image
ansible.builtin.command: "podman pull {{ metabase_image }}:{{ metabase_version }}"
register: pull_result
changed_when: pull_result.stdout is search('Writing manifest')
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for Metabase
ansible.builtin.template:
src: metabase.yaml.j2
dest: "{{ podman_projects_dir }}/metabase/metabase.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart Metabase
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for Metabase
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Deploy systemd service for Metabase (user scope)
ansible.builtin.template:
src: metabase.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/metabase.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
when: ansible_user != 'root'
- name: Enable and start Metabase service (user scope)
ansible.builtin.systemd:
name: metabase.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for Metabase
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ metabase_nginx_hostname }}"
when: metabase_nginx_enabled
- name: Deploy nginx vhost configuration for Metabase
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
owner: root
group: root
mode: "0644"
when: metabase_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for Metabase
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/metabase.conf"
state: absent
when: not metabase_nginx_enabled
notify: Reload nginx
@@ -0,0 +1,15 @@
[Unit]
Description=Metabase BI Server
[Service]
Type=notify
NotifyAccess=all
WorkingDirectory={{ podman_projects_dir }}/metabase
ExecStart=/usr/bin/podman kube play --replace --service-container=true --network=pasta:--map-host-loopback={{ podman_gw_gateway }} metabase.yaml
ExecStop=/usr/bin/podman kube down metabase.yaml
Restart=on-failure
RestartSec=10
TimeoutStartSec=180
[Install]
WantedBy=default.target
+42
View File
@@ -0,0 +1,42 @@
apiVersion: v1
kind: Pod
metadata:
name: metabase
spec:
containers:
- name: server
image: {{ metabase_image }}:{{ metabase_version }}
ports:
- containerPort: 3000
hostPort: {{ metabase_port }}
env:
- name: MB_DB_TYPE
value: postgres
- name: MB_DB_DBNAME
value: "{{ metabase_postgres_db_name }}"
- name: MB_DB_PORT
value: "{{ metabase_postgres_port }}"
- name: MB_DB_USER
value: "{{ metabase_postgres_user }}"
- name: MB_DB_PASS
value: "{{ metabase_postgres_password }}"
- name: MB_DB_HOST
value: "{{ metabase_postgres_host }}"
- name: JAVA_TIMEZONE
value: "{{ metabase_timezone }}"
volumeMounts:
- name: localtime
mountPath: /etc/localtime
readOnly: true
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 90
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
restartPolicy: Never
volumes:
- name: localtime
hostPath: { path: /etc/localtime, type: File }
@@ -0,0 +1,49 @@
# Metabase vhost
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
listen [::]:80;
server_name {{ metabase_nginx_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ metabase_nginx_hostname }};
ssl_certificate /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ metabase_nginx_hostname }}/privkey.pem;
ssl_protocols {{ nginx_ssl_protocols | default('TLSv1.3') }};
ssl_prefer_server_ciphers on;
{% if nginx_log_backend | default('journald') == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_metabase;
{% else %}
access_log /var/log/nginx/{{ metabase_nginx_hostname }}_access.log main;
error_log /var/log/nginx/{{ metabase_nginx_hostname }}_error.log;
{% endif %}
location / {
proxy_pass http://127.0.0.1:{{ metabase_port }};
proxy_set_header Host $http_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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
--- ---
- name: install oryx - name: Install oryx
cmd: paru -S oryx ansible.builtin.command: paru -S --noconfirm oryx
when: ansible_facts['os_family'] == 'Archlinux' when: ansible_facts['os_family'] == 'Archlinux'
changed_when: false
-36
View File
@@ -1,36 +0,0 @@
---
- name: Check if the interface ipv4 address is defined
when: interface.ipv4.address is not defined
block:
- ansible.builtin.debug:
msg: "Warning: iface {{ interface.name }} has no defined ipv4 address, skipping configuration"
- name: Skip net-config role for {{ interface.name }}
ansible.builtin.meta: end_play
- name: Check if the interface is already configured
ansible.builtin.stat:
path: /etc/systemd/network/20-{{ interface.name }}.network
register: network_file
- name: What patch is needed
ansible.builtin.debug:
msg: >-
{%- if network_file.stat.exists == true -%}
iface {{ interface.name }} is already configured, no action needed.
{%- else -%}
iface {{ interface.name }} will be configured.
{%- endif -%}
- name: Create systemd-network link file
when: network_file.stat.exists != true
ansible.builtin.template:
src: systemd.network.j2
dest: /etc/systemd/network/20-{{ interface.name }}.network
owner: root
group: root
mode: "0644"
- name: Notify a reload is required
ansible.builtin.set_fact:
network_reload_required: true
when: network_file.stat.exists != true
-28
View File
@@ -1,28 +0,0 @@
---
- name: "Check {{ interface.name }} ({{ interface.mac_address }}) rule"
ansible.builtin.set_fact:
interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress',
'equalto', interface.mac_address) | map(attribute='device') | first }}"
- name: What patch is needed
ansible.builtin.debug:
msg: >-
{%- if interface_original_name != interface.name -%}
iface {{ interface_original_name }} ({{ interface.mac_address }}) will be patched to {{ interface.name }}.
{%- else -%}
iface {{ interface.name }} is already set, no action needed.
{%- endif -%}
- name: Create persistent-net link file
when: interface_original_name != interface.name
ansible.builtin.template:
src: persistent-net.link.j2
dest: /etc/systemd/network/10-persistent-net-{{ interface.name }}.link
owner: root
group: root
mode: "0644"
- name: Notify a reboot is required
ansible.builtin.set_fact:
reboot_required: true
when: interface_original_name != interface.name
+23
View File
@@ -0,0 +1,23 @@
---
# UFW must be fully restarted (disable + enable) — not just reloaded — to pick
# up changes in /etc/default/ufw (DEFAULT_FORWARD_POLICY) and the *nat block
# in /etc/ufw/before.rules. See ufw(8) "RULE SYNTAX" → IP forwarding.
- name: Restart ufw (ip-forwarding settings changed)
block:
- name: Validate ufw ruleset before restart (dry-run)
ansible.builtin.command: ufw --dry-run reload
changed_when: false
- name: Disable ufw
ansible.builtin.command: ufw disable
changed_when: true
- name: Enable ufw
ansible.builtin.command: ufw --force enable
changed_when: true
- name: Verify ufw is active after restart
ansible.builtin.command: ufw status
register: ufw_status_after
changed_when: false
failed_when: "'Status: active' not in ufw_status_after.stdout"
+72
View File
@@ -0,0 +1,72 @@
---
- name: Check if the interface ipv4 address is defined
ansible.builtin.debug:
msg: "Warning: iface {{ interface.name }} has no defined ipv4 address, skipping configuration"
when: interface.ipv4.address is not defined
- name: Process interface configuration
when: interface.ipv4.address is defined
block:
- name: Create systemd-netdev file for virtual interface
when:
- interface.type is defined
- interface.type != 'ethernet'
ansible.builtin.template:
src: systemd.netdev.j2
dest: /etc/systemd/network/10-{{ interface.name }}.netdev
owner: root
group: root
mode: "0644"
register: netdev_result
- name: Create systemd-network configuration file
ansible.builtin.template:
src: systemd.network.j2
dest: /etc/systemd/network/20-{{ interface.name }}.network
owner: root
group: root
mode: "0644"
register: network_result
- name: Notify a reload is required
ansible.builtin.set_fact:
network_reload_required: true
when: netdev_result is changed or network_result is changed
## Routing & NAT (when interface has forward + masquerade enabled)
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
sysctl_set: true
reload: true
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- name: Set UFW default forward policy to ACCEPT
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: "^DEFAULT_FORWARD_POLICY="
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
notify: Restart ufw (ip-forwarding settings changed)
- name: Configure NAT masquerade in UFW before.rules for {{ interface.name }}
ansible.builtin.blockinfile:
path: /etc/ufw/before.rules
insertbefore: "^\\*filter"
marker: "# {mark} ANSIBLE MANAGED - NAT {{ interface.name }}"
block: |
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s {{ interface.ipv4.address | ansible.utils.ipaddr('network/prefix') }} -o {{ interface.ipv4.nat_out_interface }} -j MASQUERADE
COMMIT
when:
- interface.ipv4.forward | default(false)
- interface.ipv4.masquerade | default(false)
- interface.ipv4.nat_out_interface is defined
notify: Restart ufw (ip-forwarding settings changed)
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# systemd.netdev(5)
[NetDev]
Name={{ interface.name }}
Kind={{ interface.type }}
@@ -11,9 +11,20 @@ RouteMetric={{ interface.ipv4.metric }}
{% endif %} {% endif %}
[Network] [Network]
{% if interface.type is defined and interface.type == 'bridge' %}
ConfigureWithoutCarrier=yes
{% endif %}
{% if interface.ipv4.forward | default(false) %}
IPForward=ipv4
{% endif %}
{% if interface.ipv4.masquerade | default(false) %}
IPMasquerade=ipv4
{% endif %}
{% if interface.ipv4.nameservers is defined %}
{% for dns in interface.ipv4.nameservers %} {% for dns in interface.ipv4.nameservers %}
DNS={{ dns }} DNS={{ dns }}
{% endfor %} {% endfor %}
{% endif %}
{% if interface.ipv4.gateway is defined %} {% if interface.ipv4.gateway is defined %}
[Route] [Route]
+35
View File
@@ -0,0 +1,35 @@
---
- name: Skip net-persist for non-ethernet interfaces
ansible.builtin.debug:
msg: "Skipping net-persist for {{ interface.name }} (type: {{ interface.type }})"
when: interface.type is defined and interface.type != 'ethernet'
- name: Process ethernet interface persistence
when: interface.type is not defined or interface.type == 'ethernet'
block:
- name: "Check interface rule for {{ interface.name }} ({{ interface.mac_address | default('N/A') }})"
ansible.builtin.set_fact:
interface_original_name: "{{ ansible_facts.interfaces | select('in', ansible_facts) | map('extract', ansible_facts) | selectattr('pciid', 'defined') | selectattr('macaddress', 'equalto', interface.mac_address) | map(attribute='device') | first }}"
- name: What patch is needed
ansible.builtin.debug:
msg: >-
{%- if interface_original_name != interface.name -%}
iface {{ interface_original_name }} ({{ interface.mac_address }}) will be patched to {{ interface.name }}.
{%- else -%}
iface {{ interface.name }} is already set, no action needed.
{%- endif -%}
- name: Create persistent-net link file
when: interface_original_name != interface.name
ansible.builtin.template:
src: persistent-net.link.j2
dest: /etc/systemd/network/10-persistent-net-{{ interface.name }}.link
owner: root
group: root
mode: "0644"
- name: Notify a reboot is required
ansible.builtin.set_fact:
reboot_required: true
when: interface_original_name != interface.name
+2 -2
View File
@@ -6,8 +6,8 @@ This role configures the networking on the target machine.
Roles: Roles:
- net-persist - net_persist
- net-config - net_config
## Inventory Variables ## Inventory Variables
+30 -2
View File
@@ -1,7 +1,12 @@
--- ---
- name: Initialize network management variables
ansible.builtin.set_fact:
reboot_required: false
network_reload_required: false
- name: Setup persistent network interface(s) - name: Setup persistent network interface(s)
ansible.builtin.include_role: ansible.builtin.include_role:
name: net-persist name: net_persist
public: true public: true
vars: vars:
interface: "{{ item }}" interface: "{{ item }}"
@@ -9,12 +14,35 @@
- name: Configure network interface(s) - name: Configure network interface(s)
ansible.builtin.include_role: ansible.builtin.include_role:
name: net-config name: net_config
public: true public: true
vars: vars:
interface: "{{ item }}" interface: "{{ item }}"
loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}" loop: "{{ hostvars[inventory_hostname].network_interfaces | default([]) }}"
- name: Remove stale podman-gw systemd-networkd configuration
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/network/10-podman-gw.netdev
- /etc/systemd/network/20-podman-gw.network
register: stale_podman_gw
- name: Mark networkd reload required after podman-gw cleanup
ansible.builtin.set_fact:
network_reload_required: true
when: stale_podman_gw is changed
- name: Tear down podman-gw bridge interface if present
ansible.builtin.command: ip link delete podman-gw
register: podman_gw_link_del
changed_when: podman_gw_link_del.rc == 0
failed_when:
- podman_gw_link_del.rc != 0
- "'Cannot find device' not in podman_gw_link_del.stderr"
- "'does not exist' not in podman_gw_link_del.stderr"
- name: Reload networkd and resolved - name: Reload networkd and resolved
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ item }}" name: "{{ item }}"
@@ -16,3 +16,7 @@ nfs_port: 2049
nfs_server_firewall_allowed_sources: nfs_server_firewall_allowed_sources:
- 127.0.0.0/8 - 127.0.0.0/8
# OS-dependent package name
nfs_package_name: >-
{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}
@@ -1,7 +1,7 @@
--- ---
- name: Install nfs-server - name: Install nfs-server
ansible.builtin.package: ansible.builtin.package:
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('nfs-utils', 'nfs-kernel-server') }}" name: "{{ nfs_package_name }}"
state: present state: present
- name: Configure nfs configuration - name: Configure nfs configuration
@@ -28,6 +28,11 @@
state: started state: started
enabled: true enabled: true
- name: Mask nfs-server service to prevent conflicts with nfsv4-server
ansible.builtin.systemd:
name: nfs-server
masked: true
- name: Setup firewall rules for nfs on port - name: Setup firewall rules for nfs on port
community.general.ufw: community.general.ufw:
rule: allow rule: allow
+31 -9
View File
@@ -11,25 +11,42 @@ Installs and configures Nginx as a reverse proxy for web applications with modul
- SSL/TLS configuration - SSL/TLS configuration
- **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) - **Native ACME/Let's Encrypt support** (Nginx 1.25.0+)
- **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) - **Transparent proxy forwarding** (HTTP/HTTPS to other hosts)
- **Catch-all `default_server`** that rejects unknown SNI/Host with `444`
## Catch-all default_server
A `00-default.conf` vhost is deployed and marked `default_server` on both
ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`)
and returns `444` (close connection) for any request whose SNI/Host does
not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`)
are still answered on port 80 so Certbot keeps working for new hostnames.
Without this, clients hitting the server IP directly (or doing HTTP/2
connection coalescing across vhosts sharing the same IP) would receive the
certificate of the first vhost loaded alphabetically, leaking that
hostname and breaking TLS verification on other vhosts.
Disable with `nginx_default_server_enabled: false`.
## Service Integration Pattern ## Service Integration Pattern
Each service role should deploy its own vhost config: Each service role should deploy its own vhost config:
**In service role tasks:** **In service role tasks:**
```yaml ```yaml
- name: Deploy nginx vhost - name: Deploy nginx vhost
ansible.builtin.template: ansible.builtin.template:
src: nginx-vhost.conf.j2 src: nginx-vhost.conf.j2
dest: /etc/nginx/conf.d/myservice.conf dest: /etc/nginx/conf.d/myservice.conf
validate: nginx -t validate: nginx -t
when: myservice_nginx_enabled when: myservice_nginx_enabled
notify: Reload nginx notify: Reload nginx
- name: Remove nginx vhost when disabled - name: Remove nginx vhost when disabled
ansible.builtin.file: ansible.builtin.file:
path: /etc/nginx/conf.d/myservice.conf path: /etc/nginx/conf.d/myservice.conf
state: absent state: absent
when: not myservice_nginx_enabled when: not myservice_nginx_enabled
notify: Reload nginx notify: Reload nginx
``` ```
@@ -39,15 +56,17 @@ Each service role should deploy its own vhost config:
Forward TCP traffic from this Nginx instance to services on other hosts using the `stream` module (layer 4 proxy). Forward TCP traffic from this Nginx instance to services on other hosts using the `stream` module (layer 4 proxy).
**Configuration:** **Configuration:**
```yaml ```yaml
nginx_forwarder: nginx_forwarder:
"blog.hello.com": "blog.hello.com":
forward_to: "my.host.lan" forward_to: "my.host.lan"
http: true # Forward port 80 (default: true) http: true # Forward port 80 (default: true)
https: true # Forward port 443 (default: true) https: true # Forward port 443 (default: true)
``` ```
**How it works:** **How it works:**
- **Stream-based TCP proxy** (layer 4, not HTTP layer 7) - **Stream-based TCP proxy** (layer 4, not HTTP layer 7)
- No protocol inspection - just forwards raw TCP packets - No protocol inspection - just forwards raw TCP packets
- **HTTPS passes through encrypted** - backend host handles TLS termination - **HTTPS passes through encrypted** - backend host handles TLS termination
@@ -56,6 +75,7 @@ nginx_forwarder:
**Use case:** Omega (gateway) forwards all traffic to Andromeda (internal server) that handles its own TLS certificates. **Use case:** Omega (gateway) forwards all traffic to Andromeda (internal server) that handles its own TLS certificates.
**Important notes:** **Important notes:**
- Stream configs deployed to `/etc/nginx/streams.d/` - Stream configs deployed to `/etc/nginx/streams.d/`
- No HTTP logging (stream doesn't understand HTTP protocol) - No HTTP logging (stream doesn't understand HTTP protocol)
- No X-Forwarded-For headers (transparent TCP forwarding) - No X-Forwarded-For headers (transparent TCP forwarding)
@@ -64,10 +84,12 @@ nginx_forwarder:
## Logging Backends ## Logging Backends
**journald (default):** **journald (default):**
- Logs sent to systemd journal via syslog - Logs sent to systemd journal via syslog
- View: `journalctl -u nginx -f` - View: `journalctl -u nginx -f`
**file:** **file:**
- Traditional `/var/log/nginx/*.log` files - Traditional `/var/log/nginx/*.log` files
- Automatic logrotate configuration - Automatic logrotate configuration
+6 -1
View File
@@ -16,7 +16,12 @@ nginx_client_max_body_size: 100M
# SSL configuration (volontarily omit TLSv1.2 here) # SSL configuration (volontarily omit TLSv1.2 here)
nginx_ssl_protocols: TLSv1.3 nginx_ssl_protocols: TLSv1.3
nginx_ssl_prefer_server_ciphers: true
# Catch-all default_server (rejects unknown SNI / Host with 444).
# Prevents leaking the first-loaded vhost's cert to unrelated requests.
nginx_default_server_enabled: true
nginx_default_ssl_cert: /etc/nginx/ssl/default.crt
nginx_default_ssl_key: /etc/nginx/ssl/default.key
# Logging configuration # Logging configuration
# Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal) # Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal)
+87
View File
@@ -0,0 +1,87 @@
---
# Provision a Let's Encrypt certificate for a hostname using the webroot method.
#
# Required variables:
# - certbot_hostname: the domain to provision (e.g. "apk.jokester.fr")
# - acme_email: Let's Encrypt account email (typically from host_vars)
#
# Usage from a service role:
# - name: Provision TLS certificate
# ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
# vars:
# certbot_hostname: "{{ myservice_nginx_hostname }}"
# when: myservice_nginx_enabled
- name: Validate certbot requirements
ansible.builtin.assert:
that:
- certbot_hostname is defined
- certbot_hostname | length > 0
- acme_email is defined
- acme_email | length > 0
fail_msg: |
certbot_hostname and acme_email are required for certificate provisioning.
Set acme_email in host_vars and pass certbot_hostname when including this task file.
success_msg: "Certbot requirements validated for {{ certbot_hostname }}"
- name: Check if certificate already exists for {{ certbot_hostname }}
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
register: certbot_cert_file
- name: Ensure letsencrypt directories are traversable by nginx
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
when: certbot_cert_file.stat.exists
- name: Provision certificate for {{ certbot_hostname }}
when: not certbot_cert_file.stat.exists
block:
- name: Deploy temporary HTTP-only vhost for ACME challenge
ansible.builtin.template:
src: "{{ role_path }}/../nginx/templates/vhost-http-acme.conf.j2"
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
owner: root
group: root
mode: "0644"
- name: Reload nginx to activate temporary ACME vhost
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Request certificate from Let's Encrypt for {{ certbot_hostname }}
ansible.builtin.command:
cmd: >-
certbot certonly
--webroot
-w /var/www/certbot
-d {{ certbot_hostname }}
--email {{ acme_email }}
--agree-tos
--non-interactive
creates: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
- name: Fix letsencrypt directory permissions for nginx
ansible.builtin.file:
path: "{{ item }}"
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
always:
- name: Remove temporary ACME vhost
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
state: absent
- name: Reload nginx after certificate provisioning
ansible.builtin.systemd:
name: nginx
state: reloaded
+50 -2
View File
@@ -51,11 +51,16 @@
- name: Enable Certbot renewal timer - name: Enable Certbot renewal timer
ansible.builtin.systemd: ansible.builtin.systemd:
name: certbot-renew.timer name: "{{ certbot_timer }}"
enabled: true enabled: true
state: started state: started
when: acme_email is defined when: acme_email is defined
ignore_errors: true
- name: Remove default nginx vhost (Arch ships one that conflicts)
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/default.conf"
state: absent
notify: Reload nginx
- name: Ensure nginx conf.d directory exists - name: Ensure nginx conf.d directory exists
ansible.builtin.file: ansible.builtin.file:
@@ -73,6 +78,49 @@
group: root group: root
mode: "0755" mode: "0755"
- name: Configure catch-all default_server
when: nginx_default_server_enabled
block:
- name: Ensure nginx ssl directory exists
ansible.builtin.file:
path: "{{ nginx_default_ssl_cert | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Generate self-signed cert for default_server
ansible.builtin.command:
cmd: >-
openssl req -x509 -nodes -newkey rsa:2048
-keyout {{ nginx_default_ssl_key }}
-out {{ nginx_default_ssl_cert }}
-days 3650 -subj "/CN=default"
creates: "{{ nginx_default_ssl_cert }}"
- name: Restrict permissions on default_server key
ansible.builtin.file:
path: "{{ nginx_default_ssl_key }}"
owner: root
group: root
mode: "0600"
- name: Deploy default_server vhost
ansible.builtin.template:
src: default-server.conf.j2
dest: "{{ nginx_conf_dir }}/00-default.conf"
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Remove default_server vhost when disabled
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/00-default.conf"
state: absent
when: not nginx_default_server_enabled
notify: Reload nginx
- name: Ensure Certbot webroot directory exists - name: Ensure Certbot webroot directory exists
ansible.builtin.file: ansible.builtin.file:
path: /var/www/certbot path: /var/www/certbot
@@ -0,0 +1,35 @@
# Catch-all default_server vhosts
# Managed by Ansible - DO NOT EDIT MANUALLY
#
# Purpose: reject any request whose Host/SNI does not match an explicit
# server_name. Without this, the first vhost loaded alphabetically would
# leak its certificate to unrelated SNI requests (e.g. clients doing
# HTTP/2 connection coalescing or hitting the IP directly).
#
# `return 444` closes the connection without sending an HTTP response.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Keep ACME HTTP-01 challenges working for any hostname
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 444;
}
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate {{ nginx_default_ssl_cert }};
ssl_certificate_key {{ nginx_default_ssl_key }};
return 444;
}
+1 -1
View File
@@ -48,7 +48,7 @@ http {
# SSL configuration # SSL configuration
ssl_protocols {{ nginx_ssl_protocols }}; ssl_protocols {{ nginx_ssl_protocols }};
ssl_prefer_server_ciphers {{ 'on' if nginx_ssl_prefer_server_ciphers else 'off' }}; ssl_prefer_server_ciphers on;
# Load modular configuration files from the conf.d directory # Load modular configuration files from the conf.d directory
include {{ nginx_conf_dir }}/*.conf; include {{ nginx_conf_dir }}/*.conf;
-41
View File
@@ -1,41 +0,0 @@
# HTTPS vhost with Let's Encrypt (Certbot) for {{ server_name }}
# Managed by Ansible - DO NOT EDIT MANUALLY
server {
listen 80;
server_name {{ server_name }};
# Certbot webroot for ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl;
server_name {{ server_name }};
# Let's Encrypt certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/{{ server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ server_name }}/privkey.pem;
# SSL configuration
ssl_protocols {{ nginx_ssl_protocols }};
ssl_prefer_server_ciphers {{ 'on' if nginx_ssl_prefer_server_ciphers else 'off' }};
{% if nginx_log_backend == 'journald' %}
access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
error_log syslog:server=unix:/dev/log,nohostname,tag=nginx_{{ server_name | replace('.', '_') }};
{% else %}
access_log /var/log/nginx/{{ server_name }}_access.log main;
error_log /var/log/nginx/{{ server_name }}_error.log;
{% endif %}
# Service-specific configuration included below
{{ vhost_config | default('') }}
}
@@ -0,0 +1,16 @@
# Temporary HTTP-only vhost for ACME certificate provisioning
# Managed by Ansible - automatically removed after certificate issuance
server {
listen 80;
listen [::]:80;
server_name {{ certbot_hostname }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+1
View File
@@ -1,2 +1,3 @@
--- ---
nginx_user: http nginx_user: http
certbot_timer: certbot-renew.timer
+1
View File
@@ -1,2 +1,3 @@
--- ---
nginx_user: www-data nginx_user: www-data
certbot_timer: certbot.timer
+140
View File
@@ -0,0 +1,140 @@
# ntfy - Simple Notification Service
Deploys [ntfy](https://ntfy.sh/) - a simple HTTP-based pub-sub notification service.
## Security Model
**Secure by default:**
- `auth-default-access: deny-all` - No anonymous access
- `enable-signup: false` - No public registration
- `enable-login: true` - Authentication required
- `enable-reservations: true` - Only authenticated users can reserve topics
All notifications require authentication to send or receive.
## Configuration
### Required Variables
Set in inventory or vault:
```yaml
ntfy_admin_password: "your-secure-password-here" # Min 12 chars
```
### Optional Variables
See [defaults/main.yml](defaults/main.yml) for all configuration options.
Key settings:
```yaml
ntfy_version: latest
ntfy_port: 8080
ntfy_base_url: http://localhost:8080
ntfy_admin_user: admin
# Nginx reverse proxy
ntfy_nginx_enabled: false
ntfy_nginx_hostname: ntfy.nas.local
```
## Usage
### Managing Users
List users:
```bash
podman exec ntfy ntfy user list
```
Add user:
```bash
podman exec ntfy ntfy user add <username>
```
Change password:
```bash
podman exec -i ntfy ntfy user change-pass <username>
```
Remove user:
```bash
podman exec ntfy ntfy user remove <username>
```
### Managing Topic Access
Grant access to topic:
```bash
podman exec ntfy ntfy access <username> <topic> <permission>
```
Permissions: `read-write`, `read-only`, `write-only`, `deny`
Example:
```bash
# Allow user to publish and subscribe to "alerts" topic
podman exec ntfy ntfy access alice alerts read-write
# Allow user to only publish to "monitoring" topic
podman exec ntfy ntfy access bob monitoring write-only
```
List access control:
```bash
podman exec ntfy ntfy access
```
### Publishing Notifications
Using curl with authentication:
```bash
curl -u admin:password -d "Backup completed" http://localhost:8080/backups
```
Using ntfy CLI:
```bash
ntfy publish --token <access-token> ntfy.nas.local mytopic "Hello World"
```
### Subscribing to Notifications
Web UI: https://ntfy.nas.local (if nginx enabled)
CLI:
```bash
ntfy subscribe --token <access-token> ntfy.nas.local mytopic
```
Mobile apps available for iOS and Android.
## Architecture
- **Container**: Podman-based deployment
- **Storage**: Persistent cache and user database
- **Networking**: Localhost binding by default
- **Reverse Proxy**: Optional nginx with HTTPS
## File Locations
- Configuration: `{{ podman_projects_dir }}/ntfy/server.yml`
- User database: `{{ ntfy_data_dir }}/user.db`
- Cache database: `{{ ntfy_cache_dir }}/cache.db`
- Attachments: `{{ ntfy_cache_dir }}/attachments/`
## Dependencies
- podman
- nginx (if `ntfy_nginx_enabled: true`)
+29
View File
@@ -0,0 +1,29 @@
---
# Ntfy version to deploy
ntfy_version: latest
# Storage location
ntfy_data_dir: "{{ podman_projects_dir }}/ntfy/data"
ntfy_cache_dir: "{{ podman_projects_dir }}/ntfy/cache"
# Authentication configuration (REQUIRED - must be set explicitly)
# Ntfy admin user for managing topics and access control
ntfy_admin_user: admin
# ntfy_admin_password: "" # Intentionally undefined - role will fail if not set
# Network configuration
ntfy_port: 8090
# Container image
ntfy_image: binwiederhier/ntfy
# Server configuration
ntfy_base_url: http://localhost:{{ ntfy_port }}
ntfy_behind_proxy: false
ntfy_enable_signup: false # Disable public signup for security
ntfy_enable_login: true # Enable authentication
ntfy_enable_reservations: true # Only authenticated users can reserve topics
# Nginx reverse proxy configuration
ntfy_nginx_enabled: false
ntfy_nginx_hostname: ntfy.nas.local
+24
View File
@@ -0,0 +1,24 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Reload systemd user
ansible.builtin.systemd:
daemon_reload: true
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Restart ntfy
ansible.builtin.systemd:
name: ntfy.service
state: restarted
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
+3
View File
@@ -0,0 +1,3 @@
---
dependencies:
- role: podman
+158
View File
@@ -0,0 +1,158 @@
---
- name: Validate required passwords are set
ansible.builtin.assert:
that:
- ntfy_admin_password is defined
- ntfy_admin_password | length >= 12
fail_msg: |
ntfy_admin_password is required (min 12 chars).
See roles/ntfy/defaults/main.yml for configuration instructions.
success_msg: "Password validation passed"
- name: Create ntfy project directory
ansible.builtin.file:
path: "{{ podman_projects_dir | default('/opt/podman') }}/ntfy"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create ntfy data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
loop:
- "{{ ntfy_data_dir }}"
- "{{ ntfy_cache_dir }}"
- name: Deploy ntfy server configuration
ansible.builtin.template:
src: server.yml.j2
dest: "{{ podman_projects_dir | default('/opt/podman') }}/ntfy/server.yml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart ntfy
- name: Pull ntfy container image
ansible.builtin.command: "podman pull {{ ntfy_image }}:{{ ntfy_version }}"
changed_when: pull_result.stdout is search('Writing manifest')
register: pull_result
become: false
become_user: "{{ ansible_user }}"
- name: Deploy Kubernetes YAML for ntfy
ansible.builtin.template:
src: ntfy.yaml.j2
dest: "{{ podman_projects_dir | default('/opt/podman') }}/ntfy/ntfy.yaml"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Restart ntfy
- name: Get home directory for {{ ansible_user }}
ansible.builtin.getent:
database: passwd
key: "{{ ansible_user }}"
- name: Set user home directory fact
ansible.builtin.set_fact:
user_home_dir: "{{ ansible_facts['getent_passwd'][ansible_user][4] }}"
- name: Create systemd user directory for ntfy
ansible.builtin.file:
path: "{{ user_home_dir }}/.config/systemd/user"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Create systemd service for ntfy (user scope)
ansible.builtin.template:
src: ntfy.service.j2
dest: "{{ user_home_dir }}/.config/systemd/user/ntfy.service"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0644"
notify: Reload systemd user
- name: Check if lingering is enabled for {{ ansible_user }}
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ ansible_user }}"
register: linger_file
- name: Enable lingering for user {{ ansible_user }}
ansible.builtin.command: "loginctl enable-linger {{ ansible_user }}"
changed_when: true
when:
- ansible_user != 'root'
- not linger_file.stat.exists
- name: Enable and start ntfy service (user scope)
ansible.builtin.systemd:
name: ntfy.service
enabled: true
state: started
scope: user
become: false
become_user: "{{ ansible_user }}"
- name: Wait for ntfy to be ready
ansible.builtin.wait_for:
port: "{{ ntfy_port }}"
host: 127.0.0.1
timeout: 60
- name: Check if admin user already exists
ansible.builtin.command:
cmd: podman exec ntfy-server ntfy user list
register: ntfy_user_list
changed_when: false
failed_when: false
become: false
become_user: "{{ ansible_user }}"
- name: Create admin user in ntfy
ansible.builtin.shell: |
set -o pipefail
printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user add --role=admin {{ ntfy_admin_user }}
when: ntfy_admin_user not in ntfy_user_list.stdout
register: ntfy_user_create
changed_when: ntfy_user_create.rc == 0
become: false
become_user: "{{ ansible_user }}"
- name: Set admin user password
ansible.builtin.shell: |
set -o pipefail
printf '%s\n%s\n' '{{ ntfy_admin_password }}' '{{ ntfy_admin_password }}' | podman exec -i ntfy-server ntfy user change-pass {{ ntfy_admin_user }}
when: ntfy_admin_user in ntfy_user_list.stdout
changed_when: false
become: false
become_user: "{{ ansible_user }}"
- name: Provision TLS certificate for ntfy
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
vars:
certbot_hostname: "{{ ntfy_nginx_hostname }}"
when: ntfy_nginx_enabled
- name: Deploy nginx vhost configuration for ntfy
ansible.builtin.template:
src: nginx-vhost.conf.j2
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/ntfy.conf"
owner: root
group: root
mode: "0644"
when: ntfy_nginx_enabled
notify: Reload nginx
- name: Remove nginx vhost configuration for ntfy
ansible.builtin.file:
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/ntfy.conf"
state: absent
when: not ntfy_nginx_enabled
notify: Reload nginx

Some files were not shown because too many files have changed in this diff Show More