{"id":51887881,"url":"https://github.com/saegey/townhaus-caddy","last_synced_at":"2026-07-25T23:30:31.008Z","repository":{"id":360300553,"uuid":"1249521874","full_name":"saegey/townhaus-caddy","owner":"saegey","description":null,"archived":false,"fork":false,"pushed_at":"2026-07-18T16:39:21.000Z","size":196,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-18T18:18:44.980Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/saegey.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-25T19:42:37.000Z","updated_at":"2026-07-18T16:33:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/saegey/townhaus-caddy","commit_stats":null,"previous_names":["saegey/townhaus-caddy"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/saegey/townhaus-caddy","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/saegey%2Ftownhaus-caddy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/saegey%2Ftownhaus-caddy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/saegey%2Ftownhaus-caddy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/saegey%2Ftownhaus-caddy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/saegey","download_url":"https://codeload.github.com/saegey/townhaus-caddy/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/saegey%2Ftownhaus-caddy/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35896124,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-25T02:00:06.922Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-25T23:30:30.394Z","updated_at":"2026-07-25T23:30:30.993Z","avatar_url":"https://github.com/saegey.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# townhaus-caddy\n\nHomelab monorepo managing a Caddy reverse proxy stack, Frigate NVR, Raspberry Pi audio services, CamillaDSP, Beszel monitoring, and AdGuard DNS — all deployed via Ansible.\n\n## Hosts\n\n| Host | Role |\n|---|---|\n| `beelink.local` | Main server — Caddy, AdGuard, Immich, Beszel Hub |\n| `aswitch.local` | Audio Pi — Shairport, CamillaDSP, GPIO relay services |\n| `pi-cam.local` | Lounge Pi — Shairport, CamillaDSP, DAC status service |\n\n## Services\n\n### beelink.local (Docker Compose)\n\n| Service | URL | Description |\n|---|---|---|\n| Caddy | — | Reverse proxy with internal TLS CA |\n| AdGuard Home | `https://adguard` | DNS server and ad blocker |\n| Immich | `https://immich` | Photo library |\n| Frigate | `https://frigate` | NVR / camera |\n| Groovenet | `https://groovenet` | Music streaming |\n| Home Assistant | `https://ha` | Home automation |\n| Scrypted | `https://scrypted` | Camera management |\n| UniFi | `https://unifi` | Network management |\n| Beszel | `https://beszel` | Node monitoring |\n| Uptime Kuma | `https://uptime` | HTTP endpoint monitoring |\n\n### aswitch.local (systemd)\n\n| Service | Description |\n|---|---|\n| `aswitch.service` | GPIO relay — routes audio source via MQTT |\n| `audio_activity.service` | USB audio RMS detector — publishes active/inactive state |\n| `camilladsp.service` | DSP engine — EQ and processing |\n| `camillagui.service` | CamillaGUI web UI (`https://aswitch`) |\n| `shairport-sync.service` | AirPlay receiver → ALSA Loopback → CamillaDSP |\n\n### pi-cam.local (systemd)\n\n| Service | Description |\n|---|---|\n| `dac_status.service` | USB DAC presence detector — publishes to MQTT |\n| `camilladsp.service` | DSP engine — EQ and processing |\n| `camillagui.service` | CamillaGUI web UI (`https://pi-cam`) |\n| `shairport-sync.service` | AirPlay receiver → ALSA Loopback → CamillaDSP |\n\n### Audio signal chain (both Pis)\n\n```\nAirPlay source → Shairport Sync → ALSA Loopback → CamillaDSP → USB DAC → speakers\n```\n\n## Repository layout\n\n```\n.\n├── ansible/\n│   ├── ansible.cfg\n│   ├── inventory.ini           # gitignored — copy from inventory.ini.example\n│   ├── requirements.yml        # ansible-galaxy collections\n│   ├── deploy.yml              # beelink Docker stack playbook\n│   ├── group_vars/\n│   │   ├── all/main.yml        # shared vars (versions, ports, MQTT)\n│   │   ├── aswitch.yml         # aswitch-specific vars\n│   │   ├── pi_cam.yml          # pi-cam-specific vars\n│   │   └── townhaus_caddy/     # beelink vars (gitignored, copy from *.example)\n│   ├── playbooks/\n│   │   ├── beelink.yml         # AdGuard DNS + Beszel agent + Frigate config\n│   │   ├── aswitch.yml         # all aswitch services\n│   │   └── pi_cam.yml          # all pi-cam services\n│   └── roles/\n│       ├── aswitch_services/   # Python services + venv + env from 1Password\n│       ├── camilladsp/         # CamillaDSP + CamillaGUI install and config\n│       ├── shairport/          # shairport-sync.conf template\n│       ├── frigate/            # Frigate config template + 1Password secrets\n│       ├── beszel_hub/         # smartmontools for SMART monitoring\n│       ├── beszel_agent/       # Beszel agent binary + service + Hub SSH key\n│       └── adguard_dns/        # declarative DNS rewrites via AdGuard API\n├── camilladsp/\n│   └── configs/                # reference EQ presets (scp'd from aswitch)\n├── services/\n│   ├── aswitch/                # Python source (git subtree from aswitch repo)\n│   │   ├── aswitch.py\n│   │   ├── audio_activity.py\n│   │   ├── dac_status.py\n│   │   ├── home_assistant/     # example HA YAML\n│   │   └── esphome/            # ESPHome IR blaster config\n│   └── frigate/                # Frigate infra (git subtree from frigate-beelink)\n│       ├── docker-compose.yml  # standalone compose (superseded by root compose)\n│       └── config/             # config template reference\n├── Caddyfile\n└── docker-compose.yml\n```\n\n## Prerequisites\n\n```bash\nbrew install ansible 1password-cli just\nansible-galaxy collection install -r ansible/requirements.yml\nop signin\n```\n\n## First-time setup\n\n```bash\n# Inventory\ncp ansible/inventory.ini.example ansible/inventory.ini\n# Edit ansible/inventory.ini — add ansible_host=\u003cIP\u003e for any host where mDNS is unreliable\n\n# beelink vars\ncp ansible/group_vars/townhaus_caddy/main.yml.example ansible/group_vars/townhaus_caddy/main.yml\ncp ansible/group_vars/townhaus_caddy/beszel.yml.example ansible/group_vars/townhaus_caddy/beszel.yml\n# Edit both files — set 1Password refs, beelink LAN IP, AdGuard credentials\n```\n\n**1Password items required** (all in the `Homelab` vault):\n\n| Item | Fields |\n|---|---|\n| `Immich Database` | `password` |\n| `Backblaze Immich` | `key_id`, `application_key`, `restic_password` |\n| `MQTT` | `username`, `password` |\n| `AdGuard` | `username`, `password` |\n| `Frigate` | `mqtt_password`, `doorbell_rtsp_url`, `doorbell_talk_rtsp_url`, `backyard_rtsp_url`, `backyard_rtsp_sub_url`, `homekit_pin` |\n\n## Deploying\n\n```bash\n# Show every available command\njust\n\n# Deploy the Docker stack and host roles on beelink\njust deploy-beelink\n\n# Deploy either Pi\njust deploy-aswitch\njust deploy-pi-cam\n\n# Deploy all hosts\njust deploy\n\n# Validate playbook syntax and the working diff\njust check\n```\n\n## Pre-commit linting\n\nInstall the repository hooks once to run YAML checks and `ansible-lint --fix`\nbefore each commit:\n\n```bash\npipx install --python \"$(mise which python3)\" pre-commit  # if not already installed\npre-commit install\n```\n\nInstall the project's Ansible collections first with `just dependencies`.\n\nRun the Ansible lint check manually with `just lint`, or apply its available\nautomatic fixes with `just lint-fix`.\n\nThe lower-level `just deploy-stack` and `just configure-beelink` recipes are\navailable when only the Compose stack or host roles need to change.\n\nFor Beelink, deploy the Caddy stack through Ansible. The playbook renders the\nremote `.env` from `ansible/group_vars/townhaus_caddy/main.yml` and should\ncarry `GROOVENET_DOCKER_NETWORK=dj-playlist_default` and\n`GROOVENET_UPSTREAM_HOST=myapp` there. The playbook also creates the shared\nDocker network if it does not already exist.\n\nIf you intentionally start only Caddy locally instead of using Ansible, pass\nthose values in the shell environment, for example:\n\n```bash\nGROOVENET_DOCKER_NETWORK=dj-playlist_default \\\nGROOVENET_UPSTREAM_HOST=myapp \\\ndocker compose up -d caddy\n```\n\n## Dotfiles\n\nLinux hosts can optionally install your dotfiles repo into `~/.dotfiles` and\napply the Stow-managed files into the target user's home directory.\n\nThe Ansible role is opt-in and assumes Debian-family hosts. It installs the\nrequired packages, clones the repo, runs `stow --restow` for the configured\npackages, and can set the user's default shell to `zsh`.\n\nTo enable it for a host group, add variables to the relevant group vars file:\n\n```yaml\ndotfiles_enabled: true\ndotfiles_repo: https://github.com/saegey/dotfiles.git\n```\n\nFor example:\n\n- `ansible/group_vars/aswitch.yml`\n- `ansible/group_vars/pi_cam.yml`\n- `ansible/group_vars/townhaus_caddy/main.yml`\n\nBecause the repo is public, the default uses HTTPS and does not require SSH\nkeys on the target hosts. The clone is also shallow by default (`depth: 1`,\nsingle branch) because the hosts only need the current dotfiles tree, not full\nhistory.\n\nThe role intentionally does not run the dotfiles repo's interactive bootstrap\nscripts. Those scripts call `sudo` internally, which does not compose cleanly\nwith Ansible's privilege escalation. The role applies the Stow-managed files\ndirectly and installs the Debian-side prerequisites itself.\n\n## CamillaDSP presets\n\nSeven EQ presets are managed in `ansible/roles/camilladsp/templates/configs/` and deployed to both Pis. The ALSA capture/playback devices are set per-host in `group_vars`:\n\n| Preset | Description |\n|---|---|\n| `camilla.yml` | Default — light bass boost |\n| `flat.yml` | No processing |\n| `reference.yml` | Flat bass/treble at 0 dB |\n| `tonecontrol.yml` | Adjustable bass/treble |\n| `late-night.yml` | Bass and treble lift for low volumes |\n| `relaxed.yml` | Slight presence cut |\n| `warm.yml` | Bass lift, treble cut |\n\nSwitch presets via the CamillaGUI web UI (`https://aswitch` or `https://pi-cam`).\n\n## Pi-cam audio streamer\n\n`pi-cam.local` is managed through the existing `shairport`, `camilladsp`, and\n`aswitch_services` roles in `ansible/playbooks/pi_cam.yml`. The intended signal\npath is:\n\n```text\nAirPlay -\u003e Shairport Sync -\u003e ALSA Loopback -\u003e CamillaDSP -\u003e Fosi ZD3 -\u003e amp\n```\n\nFor `pi-cam`, Shairport Sync is configured to publish MQTT metadata while\nleaving the PCM stream at full scale. Home Assistant should consume the raw\nAirPlay volume metadata from MQTT and translate that into ZD3 IR volume\ncommands; Shairport itself should not attenuate the audio signal digitally.\n\nDeploy and validate:\n\n```bash\njust deploy-pi-cam\njust status-pi-cam\njust syntax-check\n```\n\nUseful checks on the Pi:\n\n```bash\nsystemctl status shairport-sync camilladsp --no-pager\njournalctl -u shairport-sync -f\njournalctl -u camilladsp -f\nshairport-sync -V\naplay -l\naplay -L\narecord -l\ncat /proc/asound/cards\n```\n\nMQTT inspection:\n\n```bash\nmosquitto_sub -h MQTT_HOST -u MQTT_USERNAME -P MQTT_PASSWORD \\\n  -t 'audio/pi-cam/shairport/#' -v\n```\n\nExpected `pi-cam` behavior:\n\n- The base Shairport topic is `audio/pi-cam/shairport`.\n- AirPlay-requested volume should be published to MQTT in the format emitted by\n  the installed `shairport-sync` build, including mute sentinels if present.\n- `shairport-sync.service` is ordered after `camilladsp.service` so playback\n  does not start before the DSP path is available.\n- The rendered Shairport config is restricted to root-readable permissions\n  because it contains MQTT credentials.\n\nPhysical Pi facts still to verify after deployment:\n\n- The exact `shairport-sync -V` output on `pi-cam`, including reported MQTT\n  support and any version-specific config syntax differences.\n- The exact MQTT subtopics and payloads emitted by the installed build for\n  volume, metadata, availability, and session state.\n- Whether `plughw:CARD=ZD3,DEV=0` and `hw:Loopback,1` remain stable across\n  reboot and USB reconnect events on this Pi.\n- Whether the stream-start pop is improved by holding CamillaDSP open and\n  fixing the Shairport output sample rate/format to `44100` and `S32_LE`.\n- Whether additional mitigation is still needed at the DAC or DSP layer.\n\n## Frigate NVR\n\nFrigate runs as a Docker service on beelink with `network_mode: host` (required for go2rtc/HomeKit local discovery). The config is deployed to `/srv/storage/frigate-config/config.yml` by the Ansible `frigate` role.\n\nAll credentials (MQTT password, camera RTSP URLs, HomeKit PIN) are stored in 1Password and rendered into the config at deploy time — no plaintext secrets in the repo.\n\n```bash\n# First-time setup\ncp ansible/group_vars/townhaus_caddy/frigate.yml.example ansible/group_vars/townhaus_caddy/frigate.yml\n# Edit frigate.yml — set frigate_mqtt_host, email, and 1Password refs\n\n# Deploy config + start container\njust deploy-beelink\n```\n\n## Immich backups\n\nImmich is backed up to a private Backblaze B2 bucket with Restic. The backup\nrole creates a fresh compressed PostgreSQL dump first, then takes an encrypted,\ndeduplicated snapshot of both the dump and the complete\n`IMMICH_UPLOAD_LOCATION`. It retains 7 daily, 5 weekly, and 12 monthly\nsnapshots. A weekly repository check reads and verifies 5% of the stored data.\n\nThe role is opt-in. To configure it:\n\n1. Create a private B2 bucket and note its S3 endpoint. Set its lifecycle rule\n   to **Keep only the last version of the file**; Restic manages backup history.\n   Do not enable Object Lock because it prevents Restic from pruning snapshots.\n2. Create an application key restricted to that bucket with read/write access.\n3. Create a `Backblaze Immich` item in the `Homelab` 1Password vault with\n   `key_id`, `application_key`, and a separately generated `restic_password`.\n   Keep that Restic password permanently; the repository cannot be recovered\n   without it. From zsh, create the item without placing secrets in shell\n   history or command arguments:\n\n   ```zsh\n   read \"B2_KEY_ID?Backblaze application key ID: \"\n   read -s \"B2_APPLICATION_KEY?Backblaze application key: \" \u0026\u0026 printf '\\n'\n   RESTIC_PASSWORD=\"$(openssl rand -base64 48)\"\n\n   op item create \\\n     --category \"Secure Note\" \\\n     --title \"Backblaze Immich\" \\\n     --vault Homelab \\\n     \"key_id[concealed]=$B2_KEY_ID\" \\\n     \"application_key[concealed]=$B2_APPLICATION_KEY\" \\\n     \"restic_password[concealed]=$RESTIC_PASSWORD\"\n\n   unset B2_KEY_ID B2_APPLICATION_KEY RESTIC_PASSWORD\n   ```\n\n   Verify that all three references resolve without revealing their values:\n\n   ```bash\n   for field in key_id application_key restic_password; do\n     op read \"op://Homelab/Backblaze Immich/$field\" \u003e/dev/null || exit 1\n   done\n   echo \"Backblaze Immich credentials are available\"\n   ```\n\n4. Copy and edit the variables file:\n\n   ```bash\n   cp ansible/group_vars/townhaus_caddy/immich_backup.yml.example \\\n     ansible/group_vars/townhaus_caddy/immich_backup.yml\n   ```\n\n5. Deploy the host roles and run the first backup manually:\n\n   ```bash\n   just deploy-beelink\n   just immich-backup\n   just immich-backup-logs\n   ```\n\nThe daily backup runs around 03:30 local time and the integrity check runs\nweekly. Inspect the schedules and most recent result with:\n\n```bash\njust immich-backup-status\n```\n\nFor a restore, recover both `/srv/storage/immich/library` and\n`/var/lib/immich-backup/immich-database.sql.gz` from the same Restic snapshot,\nthen follow Immich's database restore procedure. Test this process before\ntreating the backup as production-ready.\n\n## AdGuard DNS\n\nDNS rewrites are managed declaratively in `ansible/group_vars/townhaus_caddy/beszel.yml` under `adguard_dns_rewrites`. All short hostnames resolve to beelink's LAN IP; Caddy handles the reverse proxy. Run the beelink playbook to apply changes.\n\n## Beszel monitoring\n\nBeszel Hub runs as a Docker container on beelink (`https://beszel`). Agents run on all three hosts. The Hub's SSH public key is automatically distributed to each agent via the Ansible playbooks — no manual key copying needed.\n\nAfter running the playbooks, add each host in the Beszel UI (**Add System**) with port `45876`.\n\nbeelink runs the agent as root for SMART disk monitoring (`smartmontools` installed automatically).\n\n## Caddy internal TLS\n\nCaddy issues certs from its own local CA. Trust the root cert on your devices:\n\n```bash\nmkdir -p ./certs\ndocker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./certs/caddy-root.crt\n\n# macOS\nsudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/caddy-root.crt\n\n# Debian/Ubuntu\nsudo cp ./certs/caddy-root.crt /usr/local/share/ca-certificates/caddy-root.crt\nsudo update-ca-certificates\n```\n\n## Useful commands\n\n```bash\n# Reload Caddy config without downtime\njust caddy-reload\n\n# Follow logs\njust logs caddy\njust logs adguardhome\njust logs immich-server\njust logs beszel-hub\n\n# Check Pi service status\njust status-aswitch\njust status-pi-cam\n```\n\n## Home Assistant reverse proxy\n\nAdd to `configuration.yaml`:\n\n```yaml\nhttp:\n  use_x_forwarded_for: true\n  trusted_proxies:\n    - 172.16.0.0/12\n    - 192.168.0.0/16\n    - 10.0.0.0/8\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsaegey%2Ftownhaus-caddy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsaegey%2Ftownhaus-caddy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsaegey%2Ftownhaus-caddy/lists"}