GitOps-driven containerized service management for NAS platform
🏃 Declarative Podman container orchestration via Ansible + Quadlet
Relay manages containerized services only on a NAS host provisioned by Keystone. It follows GitOps principles: declarative, idempotent, and reproducible from Git.
# 1. Install Ansible dependencies
ansible-galaxy install -r requirements.yml
# 2. Configure inventory
vim inventory/hosts.yml # Set your NAS IP/hostname
# 3. Validate prerequisites
ansible-playbook validate.yml
# 4. Deploy services
ansible-playbook site.yml --check # Dry run first
ansible-playbook site.yml
# 5. Verify deployment
ssh <nas-ip>
systemctl status samba.service
podman psRelay implements Layer 2 (Services) in a two-layer architecture:
- Layer 1 (Keystone): Host OS, storage, Podman runtime, Tailscale
- Layer 2 (This Repository): Containerized services, applications, data workloads
| Concern | Owned By | Examples |
|---|---|---|
| Host provisioning | Keystone | OS packages, RAID setup, Podman installation |
| Service deployment | Relay | Samba, media servers, databases |
This separation ensures:
- Independent reproducibility - Host and services can be rebuilt separately
- Clear boundaries - No scope creep or responsibility overlap
- GitOps discipline - Each layer has its own source of truth
- Migration-ready - Services are portable across host platforms
- Samba - SMB/CIFS file sharing for NAS storage
See docs/SERVICES.md for detailed service catalog.
┌────────────────────────────────────────┐
│ Git Repository │ ← Single source of truth
└───────────────┬────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Ansible Playbook │ ← Declarative orchestration
└───────────────┬────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Quadlet (systemd generator) │ ← Container → systemd unit
└───────────────┬────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Podman + systemd │ ← Runtime + lifecycle mgmt
└────────────────────────────────────────┘
Quadlet is Podman's native systemd integration:
- Declarative - Container defined in
.containerfiles - systemd-native - First-class systemd unit integration
- Dependency-aware - Leverages systemd dependency graph
- Immutable - No runtime drift, containers match definitions
Traditional podman run scripts are imperative and drift-prone. Quadlet ensures containers are always in declared state.
Relay assumes the host has been provisioned by Keystone:
✅ Podman installed and configured
✅ Quadlet directory: /etc/containers/systemd
✅ Container storage on SSD: /mnt/ssd/podman
✅ Storage mounts: /mnt/ssd and /mnt/backup
✅ systemd PID 1 with multi-user.target
✅ Tailscale VPN (optional but recommended)
If Keystone has not run, Relay will fail with clear error messages.
- Firewall rules - Configure in Keystone or manually (e.g., TCP 445/139 for Samba)
- DNS/hostname - Ensure NAS is reachable from control machine
- SSH access - Passwordless sudo recommended
git clone <your-repo-url> /opt/relay
cd /opt/relay# Install Ansible collections
ansible-galaxy install -r requirements.yml
# Verify Ansible installation
ansible --version # Requires 2.15+Edit inventory/hosts.yml:
relay_services:
hosts:
nas:
ansible_host: 192.168.1.100 # Your NAS IP
ansible_user: admin # Your SSH userOr use Tailscale hostname:
ansible_host: nas.your-tailnet.ts.netansible-playbook validate.ymlThis checks:
- SSH connectivity
- Sudo access
- Keystone prerequisites (Podman, storage mounts, Quadlet directory)
- systemd status
# Dry run (see what would change)
ansible-playbook site.yml --check --diff
# Apply configuration
ansible-playbook site.yml
# Deploy specific service
ansible-playbook site.yml --tags samba# On NAS host
systemctl status samba.service
podman ps
journalctl -u samba.service -fEach service has its own configuration file in inventory/group_vars/relay_services/:
inventory/group_vars/relay_services/
├── samba.yml # Samba-specific config
└── vault.yml # Encrypted secrets (Ansible Vault)
Example samba.yml:
samba_workgroup: "HOMELAB"
samba_server_string: "My NAS"
samba_users:
- username: "alice"
password: "{{ vault_alice_password }}"
uid: 1000
gid: 1000Never commit plaintext passwords! Use Ansible Vault:
# Create encrypted vault
ansible-vault create inventory/group_vars/relay_services/vault.yml
# Add secrets
vault_alice_password: "secure_password_here"
vault_bob_password: "another_secure_password"
# Deploy with vault
ansible-playbook site.yml --ask-vault-passStore vault password securely (password manager, not in Git).
# Deploy all services
ansible-playbook site.yml
# Deploy specific service
ansible-playbook site.yml --tags samba
# Dry run (check mode)
ansible-playbook site.yml --check --diff
# Validate configuration
ansible-playbook validate.yml
# Update service image
# 1. Edit roles/[service]/defaults/main.yml
# 2. Change [service]_image_tag
# 3. Re-run playbook
ansible-playbook site.yml --tags sambaSafe to run repeatedly:
ansible-playbook site.yml # First run: changes applied
ansible-playbook site.yml # Second run: no changesOn NAS host:
# Status
systemctl status samba.service
# Logs
journalctl -u samba.service -f
# Restart
systemctl restart samba.service
# Container info
podman ps
podman logs sambarelay/
├── AGENTS.md # Architectural governance (READ FIRST)
├── README.md # This file
├── LICENSE # MIT license
├── site.yml # Main playbook
├── validate.yml # Validation playbook
├── ansible.cfg # Ansible configuration
├── requirements.yml # Ansible dependencies
│
├── inventory/
│ ├── hosts.yml # Inventory definition
│ └── group_vars/
│ └── relay_services/
│ ├── samba.yml # Samba configuration
│ └── vault.yml # Encrypted secrets (gitignored)
│
├── roles/
│ └── samba/ # Samba service role
│ ├── defaults/main.yml # Default variables
│ ├── tasks/main.yml # Deployment tasks
│ ├── templates/ # Quadlet templates
│ ├── handlers/main.yml # systemd handlers
│ └── README.md # Role documentation
│
├── docs/
│ └── SERVICES.md # Service catalog
│
└── keystone/ # Reference to host provisioning
- Git is the single source of truth
- All changes are version-controlled and reviewable
- Reproducible from scratch (
git clone→ansible-playbook)
- State is declared in Quadlet files, not scripted
- Safe to run playbooks repeatedly
- No manual
podman runcommands
- Relay: Services only (containers, Quadlets, app config)
- Keystone: Host only (OS, storage, Podman installation)
- No boundary violations
- Services run in containers via Podman
- Quadlet for systemd integration
- Images pinned to specific versions (not
latest)
- Abstracts Debian vs Fedora differences
- Designed for migration to Fedora IoT
- No OS-specific hacks
Cause: Host not provisioned by Keystone
Solution: Run Keystone playbook first to provision host
cd keystone/
ansible-playbook site.ymlCause: Firewall blocking SMB ports
Solution: Configure firewall in Keystone or manually
# Check if ports are open
sudo ss -tlnp | grep -E '445|139'
# If needed, open ports (Keystone's responsibility)
# Example for firewalld:
sudo firewall-cmd --add-port=445/tcp --permanent
sudo firewall-cmd --add-port=139/tcp --permanent
sudo firewall-cmd --reloadCheck logs:
journalctl -u samba.service -n 50Check Quadlet file:
cat /etc/containers/systemd/samba.container
systemctl cat samba.service # View generated unitValidate manually:
podman run --rm -it dperson/samba --helpEnsure handlers run:
# Handlers trigger on template changes
ansible-playbook site.yml --tags samba
# Force handler execution
ansible-playbook site.yml --tags samba --force-handlersManual reload:
sudo systemctl daemon-reload
sudo systemctl restart samba.service- AGENTS.md - Architectural contract and governance (READ FIRST)
- roles/samba/README.md - Samba service documentation
- docs/SERVICES.md - Service catalog
- Keystone README - Host provisioning system
- Read AGENTS.md first - Understand scope and boundaries
- Follow conventions - Use Quadlet, pin versions, be idempotent
- Test thoroughly - Dry run, idempotency check, manual verification
- Document decisions - Explain why, not just what
- Commit semantically - Use Conventional Commits
- Create role:
roles/[service-name]/ - Follow standard structure (see
roles/samba/as example) - Add Quadlet template:
templates/[service].container.j2 - Update playbook: Add role to
site.yml - Document: Create
roles/[service-name]/README.md - Test: Validate idempotency and functionality
See LICENSE
- Keystone - Host provisioning layer
- Podman - Container runtime
- Quadlet - systemd integration for containers
- Ansible - Infrastructure as Code