commit 6c89f12fad8247bda27d090d4079f5bcdc2a7a3e Author: Tim Bendt Date: Thu Jun 25 22:45:18 2026 -0400 first commit diff --git a/homelab-iac/.gitignore b/homelab-iac/.gitignore new file mode 100644 index 0000000..7394c9c --- /dev/null +++ b/homelab-iac/.gitignore @@ -0,0 +1,13 @@ +.sops/ +*.age +*.key +*.pem +*.sql +*.tar.gz +acme.json +secrets.sops.yml.dec +exports/ +__pycache__/ +*.pyc +.vscode/ +.idea/ diff --git a/homelab-iac/.sops.yaml b/homelab-iac/.sops.yaml new file mode 100644 index 0000000..917df6a --- /dev/null +++ b/homelab-iac/.sops.yaml @@ -0,0 +1,5 @@ +creation_rules: + - path_regex: "inventory/group_vars/.*\\.sops\\.yml$" + age: "age1g759cwnlt6nq5u87jcs6m76fz3spw5awcwrchuck8ythxpf4g9rqarhy9y" + - path_regex: "files/.*\\.sops\\.*$" + age: "age1g759cwnlt6nq5u87jcs6m76fz3spw5awcwrchuck8ythxpf4g9rqarhy9y" diff --git a/homelab-iac/Makefile b/homelab-iac/Makefile new file mode 100644 index 0000000..74611ae --- /dev/null +++ b/homelab-iac/Makefile @@ -0,0 +1,49 @@ +SOPS_KEY ?= .sops/age-key.txt +export SOPS_AGE_KEY_FILE=$(SOPS_KEY) + +.PHONY: help secrets edit-secrets decrypt-secrets bootstrap swarm dokploy supporting backup dry-run lint + +help: + @echo "Available targets:" + @echo " init - Install Ansible collections" + @echo " edit-secrets - Edit encrypted secrets" + @echo " decrypt-secrets - View encrypted secrets" + @echo " dry-run - Syntax-check all playbooks" + @echo " bootstrap - Bootstrap all nodes (Docker, sysctl, NFS)" + @echo " swarm - Initialize/join Swarm and apply labels" + @echo " dokploy - Deploy Dokploy control plane" + @echo " supporting - Deploy registry, gitea, DNS" + @echo " backup - Backup Dokploy state and volumes" + @echo " all - Run bootstrap + swarm + dokploy + supporting" + +init: + ansible-galaxy collection install -r requirements.yml + +edit-secrets: + sops inventory/group_vars/all/secrets.sops.yml + +decrypt-secrets: + sops --decrypt inventory/group_vars/all/secrets.sops.yml + +dry-run: + ansible-playbook -i inventory/production.yml --syntax-check playbooks/*.yml + +export: + ansible-playbook -i inventory/production.yml playbooks/91_export_dokploy.yml + +bootstrap: + ansible-playbook -i inventory/production.yml playbooks/00_bootstrap_nodes.yml + +swarm: + ansible-playbook -i inventory/production.yml playbooks/10_swarm_init.yml + +dokploy: + ansible-playbook -i inventory/production.yml playbooks/20_dokploy.yml + +supporting: + ansible-playbook -i inventory/production.yml playbooks/30_supporting.yml + +backup: + ansible-playbook -i inventory/production.yml playbooks/90_backup.yml + +all: bootstrap swarm dokploy supporting diff --git a/homelab-iac/README.md b/homelab-iac/README.md new file mode 100644 index 0000000..35a2eaa --- /dev/null +++ b/homelab-iac/README.md @@ -0,0 +1,57 @@ +# Homelab IaC + +Ansible-based infrastructure-as-code for a mixed-arch (ARM/x86) Docker Swarm managed by Dokploy. + +## Quick start + +1. Install prerequisites on your control machine: + ```bash + brew install ansible sops age + ``` + +2. Generate an Age key for SOPS: + ```bash + ./scripts/init-age.sh + ``` + Update `.sops.yaml` with the public key printed by the script. + +3. Edit encrypted secrets: + ```bash + sops inventory/group_vars/all/secrets.sops.yml + ``` + Fill in the values captured from the live cluster. + +4. Bootstrap nodes and deploy Dokploy: + ```bash + ansible-playbook -i inventory/production.yml playbooks/00_bootstrap_nodes.yml + ansible-playbook -i inventory/production.yml playbooks/10_swarm_init.yml + ansible-playbook -i inventory/production.yml playbooks/20_dokploy.yml + ``` + +## Capturing live secrets + +On the manager (`tpi-n1`), run: + +```bash +# Docker secrets (values are base64 in inspect output) +docker secret inspect dokploy_postgres_password --format='{{range .}}{{.Spec.Data}}{{end}}' | base64 -d +docker secret inspect dokploy-auth-secret --format='{{range .}}{{.Spec.Data}}{{end}}' | base64 -d + +# Registry HTTP secret +grep REGISTRY_HTTP_SECRET /etc/dokploy/compose/gitea-registry-vdftrt/code/.env + +# Technitium admin password +grep DNS_SERVER_ADMIN_PASSWORD /etc/dokploy/compose/dns-technitum-6ojgo2/code/.env +``` + +Place these into `inventory/group_vars/all/secrets.sops.yml`. + +## Maintenance tasks + +See [docs/maintenance.md](docs/maintenance.md). + +## Runbooks + +- [Restore Dokploy from backup](docs/runbook-restore-dokploy.md) +- [Add a new Swarm node](docs/runbook-add-node.md) +- [Rotate secrets](docs/runbook-rotate-secrets.md) diff --git a/homelab-iac/docs/maintenance.md b/homelab-iac/docs/maintenance.md new file mode 100644 index 0000000..332716c --- /dev/null +++ b/homelab-iac/docs/maintenance.md @@ -0,0 +1,58 @@ +# Maintenance tasks + +These are operational tasks that are NOT fully automated yet and need your attention on a schedule. + +## Immediate / urgent + +- [ ] **tpi-n2 disk space**: `/dev/mmcblk0p2` is 100% full (27G / 29G). Clean up Docker build cache, old images, logs, and unused volumes before it causes service failures. + ```bash + ssh ubuntu@192.168.2.19 + docker system prune -a --volumes + journalctl --vacuum-time=7d + du -sh /var/lib/docker/* /var/log/* + ``` + +- [ ] **Capture live secrets** and place them into `inventory/group_vars/all/secrets.sops.yml`: + ```bash + ssh ubuntu@192.168.2.130 + docker secret inspect dokploy_postgres_password --format='{{range .}}{{.Spec.Data}}{{end}}' | base64 -d + docker secret inspect dokploy-auth-secret --format='{{range .}}{{.Spec.Data}}{{end}}' | base64 -d + grep REGISTRY_HTTP_SECRET /etc/dokploy/compose/gitea-registry-vdftrt/code/.env + grep DNS_SERVER_ADMIN_PASSWORD /etc/dokploy/compose/dns-technitum-6ojgo2/code/.env + cat /etc/dokploy/compose/gitea-registry-vdftrt/files/auth/registry.password + ``` + +- [ ] **Generate Age key** and configure SOPS: + ```bash + ./scripts/init-age.sh + # edit .sops.yaml with the public key + sops inventory/group_vars/all/secrets.sops.yml + ``` + +- [ ] **Take first encrypted backups** of Dokploy DB, `/etc/dokploy`, and `acme.json`. + +## Weekly + +- [ ] Review disk usage on all nodes (`df -h`, `docker system df -v`). +- [ ] Verify Dokploy DB backups completed and are restorable. +- [ ] Check `acme.json` is backed up (TLS cert loss means rate-limit pain). +- [ ] Review failed / 0-replica services (`docker service ls`). + +## Monthly + +- [ ] Update Docker CE to latest patch release on a test node first. +- [ ] Review Dokploy release notes and plan upgrades (`dokploy_version`). +- [ ] Rotate any manually-set passwords stored outside SOPS. +- [ ] Test restore procedure from backup on a spare VM or cloud instance. +- [ ] Prune old container images, build caches, and Dokploy logs. + +## Quarterly + +- [ ] Rotate Ansible Vault / Age key and re-encrypt secrets. +- [ ] Validate IaC still reproduces the cluster from scratch on a test environment. +- [ ] Audit user access in Dokploy and Gitea. +- [ ] Review offsite backup integrity. + +## Before any disaster + +- [ ] Ensure `secrets.sops.yml`, the Age private key, and the latest `.tar.gz.age` backup are stored in at least two offline locations. diff --git a/homelab-iac/docs/runbook-add-node.md b/homelab-iac/docs/runbook-add-node.md new file mode 100644 index 0000000..41518c6 --- /dev/null +++ b/homelab-iac/docs/runbook-add-node.md @@ -0,0 +1,22 @@ +# Runbook: Add a new Swarm node + +1. Add the node to `inventory/production.yml` under the correct group (`swarm_managers` or `swarm_workers`). + +2. Run bootstrap: + ```bash + ansible-playbook -i inventory/production.yml playbooks/00_bootstrap_nodes.yml --limit newnode + ``` + +3. Run Swarm join: + ```bash + ansible-playbook -i inventory/production.yml playbooks/10_swarm_init.yml --limit newnode + ``` + +4. Verify labels and placement: + ```bash + ssh ubuntu@192.168.2.130 + docker node ls + docker node inspect newnode --pretty + ``` + +5. If the node is a manager, ensure an odd number of managers (1, 3, 5). diff --git a/homelab-iac/docs/runbook-restore-dokploy.md b/homelab-iac/docs/runbook-restore-dokploy.md new file mode 100644 index 0000000..972a8cd --- /dev/null +++ b/homelab-iac/docs/runbook-restore-dokploy.md @@ -0,0 +1,63 @@ +# Runbook: Restore Dokploy from backup + +## Scenario + +The Dokploy control plane (Postgres, Redis, Traefik) has been lost but Swarm is intact or rebuilt. + +## Prerequisites + +- Age private key available. +- Latest backup bundle: `*.tar.gz.age`. +- Ansible inventory is correct. + +## Steps + +1. Deploy the base Swarm and Dokploy stack WITHOUT starting the dokploy service: + ```bash + ansible-playbook -i inventory/production.yml playbooks/00_bootstrap_nodes.yml + ansible-playbook -i inventory/production.yml playbooks/10_swarm_init.yml + ansible-playbook -i inventory/production.yml playbooks/20_dokploy.yml + ``` + +2. Stop the Dokploy app and Postgres services: + ```bash + ssh ubuntu@192.168.2.130 + docker service scale dokploy_dokploy=0 dokploy_dokploy-postgres=0 + ``` + +3. Restore the Postgres volume: + ```bash + docker run --rm -v dokploy-postgres-database:/target -v /path/to/restore:/backup alpine \ + sh -c "rm -rf /target/* && tar xzf /backup/vol-dokploy-postgres-database.tar.gz -C /target" + ``` + +4. Restore `/etc/dokploy`: + ```bash + rm -rf /etc/dokploy + tar xzf /path/to/restore/etc-dokploy.tar.gz -C / + ``` + +5. Restore `acme.json`: + ```bash + cp /path/to/restore/acme.json /etc/dokploy/traefik/dynamic/acme.json + chmod 600 /etc/dokploy/traefik/dynamic/acme.json + ``` + +6. Scale services back up: + ```bash + docker service scale dokploy_dokploy-postgres=1 dokploy_dokploy=1 + ``` + +7. Verify: + ```bash + docker service ls + docker logs -f dokploy_dokploy.1. + ``` + +## If Swarm is completely gone + +- Rebuild nodes with `00_bootstrap_nodes.yml`. +- Join as a new Swarm. +- Re-create Docker secrets with the same values from `secrets.sops.yml` before running `20_dokploy.yml`. +- Restore volumes and `/etc/dokploy` as above. +- Re-deploy application stacks through Dokploy UI or API once Dokploy is running. diff --git a/homelab-iac/inventory/group_vars/all/main.yml b/homelab-iac/inventory/group_vars/all/main.yml new file mode 100644 index 0000000..a5c8414 --- /dev/null +++ b/homelab-iac/inventory/group_vars/all/main.yml @@ -0,0 +1,51 @@ +--- +# Unencrypted group variables. +# Place sensitive values in inventory/group_vars/all/secrets.sops.yml + +# OS packages +common_packages: + - ca-certificates + - curl + - gnupg + - lsb-release + - python3-pip + - python3-docker + - jq + - nvme-cli + - nfs-common + - age + +# Docker CE version pinning (empty = latest available) +docker_version: "" +docker_compose_plugin: true + +# Swarm +docker_swarm_init: true + +# Dokploy deployment directory on the first manager +dokploy_etc_dir: /etc/dokploy +dokploy_compose_dir: /opt/dokploy + +# Registry settings +registry_image: registry:2 +registry_http_secret: "{{ vault_registry_http_secret }}" +registry_htpasswd_path: files/registry/htpasswd + +# NFS shared storage +nfs_subdir_name: shared +minio_data_dir: /mnt/swarm-data/minio + +# Backup settings +backup_retention_days: 30 +backup_base_dir: /mnt/swarm-data/backups/homelab + +# MinIO S3 backup target (on Synology NAS) +backup_s3_enabled: true +backup_s3_endpoint: "http://192.168.2.18:9000" +backup_s3_bucket: "dokploy-backups" +backup_s3_region: "us-east-1" +backup_s3_path: "homelab" +backup_s3_storage_class: "" + +# Monitoring / alerting placeholders +alert_email: "" diff --git a/homelab-iac/inventory/group_vars/all/secrets.sops.yml b/homelab-iac/inventory/group_vars/all/secrets.sops.yml new file mode 100644 index 0000000..d5c71ec --- /dev/null +++ b/homelab-iac/inventory/group_vars/all/secrets.sops.yml @@ -0,0 +1,32 @@ +#ENC[AES256_GCM,data:qAF9E1uTKEk9olKbTZmdyi/Sdlwa7AU+82M=,iv:2Z1D5EeueXR+2GAePPMNzRr9Tt3Db9PkFRlBDYF3Pew=,tag:pF7CxjMFjs0a2sCBVJq9xg==,type:comment] +#ENC[AES256_GCM,data:5Xk//VJYvefv0mZcLbDW1O46Ha7rG4S1ts3G3gqA0T1aMDku898FzElNzVDo6xIjAU02CMilr1NTMQ==,iv:0w9+c/ZjeVCXoZqCER97CpU7RpP5cEQrrN/1Qj0Kz5M=,tag:VPWTZFT2lFRCz5bbRp70zg==,type:comment] +vault_dokploy_postgres_password: ENC[AES256_GCM,data:UoDCPLC/NXNLc08VxurgKmNEopiT2cOAin5k7bEWWOg=,iv:m9Omki8b123DCZXywIfLdocI98xW6nh2JwrorXWDd24=,tag:3oJDQV7sO74RielR7YlIyA==,type:str] +vault_dokploy_auth_secret: ENC[AES256_GCM,data:+D1yrIxtRNMRdNNft0ieTPBKMvSjBqU/Ygm+BjfBtztfjWQJUgV3ENTrKiUyR+o/ifPilKQzZXqBhyHXtAUNKg==,iv:/qJA4o4ZrpUDilXw21X5OjHFjdBxyvCkKRXuc1PUaOQ=,tag:gcOFk4j9FmEpqttXRALOqw==,type:str] +vault_registry_http_secret: ENC[AES256_GCM,data:BXoG+/k6InJKImK3KEADb2ppU/jM4q7UC4sLzhjj,iv:kFNBGZnwyTPB0nWW0EZSiH/xO9UP5qccCs61xeq8010=,tag:AwFteLIuNMg2dX6WXJcK2A==,type:str] +vault_technitium_admin_password: ENC[AES256_GCM,data:rbaJvjZuxW5SoujvPmh07g==,iv:DTjTF2/pzlkaf7UAg9S31oLU6AQQ+0M3q9yK+TcIAHA=,tag:6uQUO1bt2zj4rTCroFQgBg==,type:str] +vault_registry_htpasswd: ENC[AES256_GCM,data:RVFpZDDwEHxirJu+81GTkYAOXHxHd0Xj0RUvB+d+vcrXjntLAe/0b/Pcv2cFogB8q5X0nD8SGyQnokX7sPvU8Ig+GQ==,iv:HTS6nA1054KZ0BMGPzp1aoIzjZaxvFypU6lDvCU1yKs=,tag:XAsD7Qo0zZpD1nJ6Dep+8Q==,type:str] +#ENC[AES256_GCM,data:+bAUnUUaSmOGlAW7OphyUkHp6rpHaWp/4CTCdjPz/lYK1nAvgNk=,iv:TSVBbanbgB94hqa8e9mKvF+r/3V3NXyFeFx5afoXWP4=,tag:U+rsgcOS6tB60LY9VfKUAA==,type:comment] +#ENC[AES256_GCM,data:1AcztTHc96l/Ok9yPpHFqA1CRkl8lLllc5/ePhIP8g==,iv:8J+8kkWYWyfDKzGA0dnL2l7sKHZ5TMeZ+vuF6UaZQ8s=,tag:uitNuP/Fv1Qza+UUi0I4XA==,type:comment] +#ENC[AES256_GCM,data:yLiFcWg316L+HgNxDR6QMaOIcdGz8T0DS/XGHx0dvQ==,iv:ts2vr6giSBQcFB3QSKG9+oQDZa3Qb4uro+JIxmMFgwk=,tag:lx+ABAfuBKXJ/wSiuCRgvw==,type:comment] +#ENC[AES256_GCM,data:PWm0slUV/mmt0l10WEie1sF3hpe86G3f5puobqs=,iv:Uhuj0i08uO+LbCB1nxFTaDVAwe60d91iF8qN1MdY9GI=,tag:5mFfJ46anoS5OLCtLHQOfw==,type:comment] +#ENC[AES256_GCM,data:IOECwipNIQmzD5Z8qRrIX07IkjuXjiUU11Hw,iv:BslYy41gJdiAiBtio/oe/Yk++yV4LN4ZtqnPXK/w3+Y=,tag:1MHjDhbia8myT4bL1zJGVw==,type:comment] +vault_backup_s3_access_key: ENC[AES256_GCM,data:WTmPqGHFmLe3y17aB4c=,iv:qN0d0yhhxjucNgD2bT4HChsScZ5o5rdoayhnP4jGHZQ=,tag:x3xu6Z34EK/2qSHlFWnVBg==,type:str] +vault_backup_s3_secret_key: ENC[AES256_GCM,data:rYdFbL7cEtoDcJLUogcunqPM++yVycNZXtNbfT2YmBU217/MOya5N1nbEy8=,iv:PNDkoI5I19uBKuIG8zGylrwm3pOgR7qAWotkUsP60Rc=,tag:hUO2QFHI79sJM8Way/vMag==,type:str] +vault_minio_root_user: ENC[AES256_GCM,data:2P9+k9Q=,iv:KHh3+vYf2E6ArZMI7al9lRLeiyG+EZ4ApprBfIwhbxA=,tag:TQqOjYU4QwnoQoYv6oRgFg==,type:str] +vault_minio_root_password: ENC[AES256_GCM,data:iz1M/3sHGoca0Ag6pxRP,iv:m36+kif7BROc4KKWaIlN9MFA8wa+gxXs6R1D16FD3MU=,tag:Xm8XWIPU1RkOrNY0DbNhkQ==,type:str] +vault_nas_sudo_password: ENC[AES256_GCM,data:8TVzULuNcUQqrfyI,iv:OF64G6vykCvRJOicUsFmxcMtLS9YIKRRrZ8VmAkkJx4=,tag:eFtpB1Db4rEbvhxQ8z2Sig==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2cHhjZERWRjdyc1FsdkR1 + bUttdTZPbjBPenZqUC85dGZ6aUlYTmtXblZZCi9NV0lGRU5SdWw3Kzl2NWU2bXlR + REZTNkcxajIzclF6OTBsMzV5WnJ6TzgKLS0tIHJFRVJ3RVQ0T2xycWtGcmRTV0ZZ + bkVrQTRLUUJxTXpQcUs4amNHdi9nQkEK9QphmUym4WxSbFcGzkkjkWemeSldP6ng + ILe8MOejUdOmLUH5N6KpYy26CHuX1bjx/e1OMRPtjHan4a6WrsIklg== + -----END AGE ENCRYPTED FILE----- + recipient: age1g759cwnlt6nq5u87jcs6m76fz3spw5awcwrchuck8ythxpf4g9rqarhy9y + lastmodified: "2026-06-26T01:33:15Z" + mac: ENC[AES256_GCM,data:NwX7stu3VDNTEyf2xrW0qhigtZS7B3zvu7Pt2M9uIpE7XTU07UzEL+yWpuhmSZ8EgvIt9cVvpmzsEuIYzl1l+eXdY7wVCr/s5FQyr4GCU2G81KlAv2TUYXqDEu/T5be7+CxpNu1VZYTbM4o9+MCoGb00E5oEkOJCdALwzMW2o14=,iv:1VHSe1gqfKi8geRnykEuqjes3pNSNxUtR/q+DBdC2m4=,tag:O5nPfqCuyBou6t+j/T5XrQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 diff --git a/homelab-iac/inventory/production.yml b/homelab-iac/inventory/production.yml new file mode 100644 index 0000000..089c968 --- /dev/null +++ b/homelab-iac/inventory/production.yml @@ -0,0 +1,84 @@ +--- +# Homelab production inventory +# Nodes are mixed-arch Docker Swarm managed by Dokploy. + +all: + children: + swarm_managers: + hosts: + tpi-n1: + ansible_host: 192.168.2.130 + ansible_user: ubuntu + swarm_advertise_addr: 192.168.2.130 + docker_data_root: /mnt/nvme/docker + node_labels: + infra: "true" + role: storage + storage: high + nfs_mounts: + - src: "192.168.2.59:/volume1/swarm-data" + dst: /mnt/swarm-data + opts: defaults + - src: "192.168.2.59:/volume1/photo" + dst: /mnt/photos + opts: defaults + + swarm_workers: + hosts: + tpi-n2: + ansible_host: 192.168.2.19 + ansible_user: ubuntu + docker_data_root: /var/lib/docker + node_labels: + npu: "true" + rk1: "true" + nfs_mounts: + - src: "192.168.2.59:/volume1/swarm-data" + dst: /mnt/swarm-data + opts: defaults + - src: "192.168.2.59:/volume1/photo" + dst: /mnt/photos + opts: defaults + node-nas: + ansible_host: 192.168.2.18 + ansible_user: tim + docker_data_root: /mnt/nvme/docker + node_labels: + type: nas + nfs_mounts: + - src: "192.168.2.59:/volume1/swarm-data" + dst: /mnt/swarm-data + opts: defaults + - src: "192.168.2.59:/volume1/photo" + dst: /mnt/photos + opts: defaults + + docker_hosts: + children: + swarm_managers: + swarm_workers: + + vars: + ansible_python_interpreter: /usr/bin/python3 + ansible_ssh_common_args: "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + # Swarm settings + swarm_manager_address: 192.168.2.130 + swarm_manager_port: 2377 + + # Dokploy version and networking + dokploy_version: v0.29.8 + dokploy_traefik_version: v3.6.1 + dokploy_postgres_version: "16" + dokploy_redis_version: "7" + dokploy_network_name: dokploy-network + dokploy_ui_port: 3000 + dokploy_traefik_dashboard_port: 8080 + + # DNS / Docker daemon defaults + homelab_dns_server: 192.168.2.130 + fallback_dns: + - 8.8.8.8 + - 8.8.4.4 + insecure_registries: + - registry.lan diff --git a/homelab-iac/playbooks/00_bootstrap_nodes.yml b/homelab-iac/playbooks/00_bootstrap_nodes.yml new file mode 100644 index 0000000..f596f4a --- /dev/null +++ b/homelab-iac/playbooks/00_bootstrap_nodes.yml @@ -0,0 +1,161 @@ +--- +- name: Bootstrap all Docker Swarm nodes + hosts: docker_hosts + become: true + gather_facts: true + + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + when: ansible_os_family == "Debian" + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + when: ansible_os_family == "Debian" + + - name: Ensure Docker keyring directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + when: ansible_os_family == "Debian" + + - name: Check if Docker is already installed + ansible.builtin.command: docker --version + register: docker_installed + ignore_errors: true + changed_when: false + + - name: Add Docker GPG key + ansible.builtin.shell: | + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + args: + creates: /etc/apt/keyrings/docker.gpg + executable: /bin/bash + when: + - ansible_os_family == "Debian" + - docker_installed.rc != 0 + + - name: Add Docker apt repository (deb822) + ansible.builtin.deb822_repository: + name: docker + types: deb + uris: "https://download.docker.com/linux/ubuntu" + suites: "{{ ansible_lsb.codename | default(ansible_distribution_release) }}" + components: stable + signed_by: /etc/apt/keyrings/docker.gpg + architectures: "{{ ansible_architecture | regex_replace('aarch64', 'arm64') | regex_replace('x86_64', 'amd64') }}" + state: present + when: + - ansible_os_family == "Debian" + - docker_installed.rc != 0 + + - name: Install Docker CE + ansible.builtin.apt: + name: + - docker-ce{% if docker_version %}{{ "=" ~ docker_version }}{% endif %} + - docker-ce-cli{% if docker_version %}{{ "=" ~ docker_version }}{% endif %} + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: "{% if docker_version %}present{% else %}latest{% endif %}" + update_cache: true + when: + - ansible_os_family == "Debian" + - docker_installed.rc != 0 + + - name: Ensure Docker socket is enabled and started + ansible.builtin.systemd: + name: docker.socket + state: started + enabled: true + + - name: Ensure Docker service is running + ansible.builtin.systemd: + name: docker + state: started + enabled: true + + - name: Add user to docker group + ansible.builtin.user: + name: "{{ ansible_user }}" + groups: docker + append: true + + - name: Ensure Docker data-root directory exists + ansible.builtin.file: + path: "{{ docker_data_root }}" + state: directory + mode: "0711" + + - name: Configure Docker daemon + ansible.builtin.template: + src: docker-daemon.json.j2 + dest: /etc/docker/daemon.json + mode: "0600" + notify: restart docker + + - name: Configure sysctl for Swarm / containers + ansible.posix.sysctl: + name: "{{ item.name }}" + value: "{{ item.value }}" + state: present + reload: true + loop: + - { name: net.ipv4.ip_forward, value: "1" } + - { name: vm.max_map_count, value: "1048576" } + + - name: Ensure ansible user has passwordless sudo + ansible.builtin.lineinfile: + path: /etc/sudoers.d/99-ansible-user + line: "{{ ansible_user }} ALL=(ALL) NOPASSWD:ALL" + create: true + mode: "0440" + validate: /usr/sbin/visudo -cf %s + + - name: Mount NFS shares + ansible.posix.mount: + src: "{{ item.src }}" + path: "{{ item.dst }}" + fstype: nfs + opts: "{{ item.opts }}" + state: mounted + loop: "{{ nfs_mounts | default([]) }}" + when: nfs_mounts is defined + tags: + - nfs + - mounts + + - name: Ensure NFS-backed subdirectories exist + ansible.builtin.file: + path: "{{ item.dst }}/{{ subdir }}" + state: directory + mode: "0755" + loop: "{{ nfs_mounts | default([]) }}" + loop_control: + label: "{{ item.dst }}/{{ subdir }}" + vars: + subdir: "{{ nfs_subdir_name }}" + when: + - nfs_mounts is defined + - nfs_subdir_name is defined + tags: + - nfs + - mounts + + - name: Ensure MinIO data directory exists + ansible.builtin.file: + path: "{{ minio_data_dir }}" + state: directory + mode: "0700" + when: minio_data_dir is defined + + handlers: + - name: restart docker + ansible.builtin.systemd: + name: docker + state: restarted diff --git a/homelab-iac/playbooks/10_swarm_init.yml b/homelab-iac/playbooks/10_swarm_init.yml new file mode 100644 index 0000000..7456f48 --- /dev/null +++ b/homelab-iac/playbooks/10_swarm_init.yml @@ -0,0 +1,68 @@ +--- +- name: Initialize and join Docker Swarm + hosts: docker_hosts + become: true + gather_facts: true + vars: + manager_token: "{{ hostvars[groups['swarm_managers'][0]]['swarm_manager_token'] | default(omit) }}" + worker_token: "{{ hostvars[groups['swarm_managers'][0]]['swarm_worker_token'] | default(omit) }}" + + tasks: + - name: Check current Swarm status + ansible.builtin.command: docker info --format '{{ '{{' }} .Swarm.LocalNodeState {{ '}}' }}' + register: swarm_status + changed_when: false + + - name: Initialize Swarm on first manager + ansible.builtin.command: | + docker swarm init --advertise-addr {{ swarm_advertise_addr }}:{{ swarm_manager_port }} + when: + - inventory_hostname in groups['swarm_managers'] + - swarm_status.stdout == "inactive" + register: swarm_init + + - name: Retrieve Swarm tokens + ansible.builtin.command: "docker swarm {{ item }} -q" + loop: + - join-token manager + - join-token worker + register: swarm_tokens + changed_when: false + run_once: true + delegate_to: "{{ groups['swarm_managers'][0] }}" + when: inventory_hostname == groups['swarm_managers'][0] + + - name: Set tokens as host facts + ansible.builtin.set_fact: + swarm_manager_token: "{{ swarm_tokens.results[0].stdout }}" + swarm_worker_token: "{{ swarm_tokens.results[1].stdout }}" + run_once: true + delegate_to: "{{ groups['swarm_managers'][0] }}" + + - name: Join additional managers + ansible.builtin.command: | + docker swarm join --token {{ hostvars[groups['swarm_managers'][0]]['swarm_manager_token'] }} + {{ swarm_manager_address }}:{{ swarm_manager_port }} + --advertise-addr {{ swarm_advertise_addr | default(ansible_default_ipv4.address) }}:{{ swarm_manager_port }} + when: + - inventory_hostname in groups['swarm_managers'] + - swarm_status.stdout == "inactive" + - inventory_hostname != groups['swarm_managers'][0] + + - name: Join workers + ansible.builtin.command: | + docker swarm join --token {{ hostvars[groups['swarm_managers'][0]]['swarm_worker_token'] }} + {{ swarm_manager_address }}:{{ swarm_manager_port }} + --advertise-addr {{ ansible_default_ipv4.address }}:{{ swarm_manager_port }} + when: + - inventory_hostname in groups['swarm_workers'] + - swarm_status.stdout == "inactive" + + - name: Apply node labels + ansible.builtin.command: | + docker node update --label-add {{ item.key }}={{ item.value }} {{ inventory_hostname_short }} + loop: "{{ node_labels | default({}) | dict2items }}" + when: inventory_hostname in groups['swarm_managers'] or inventory_hostname in groups['swarm_workers'] + run_once: true + delegate_to: "{{ groups['swarm_managers'][0] }}" + changed_when: true diff --git a/homelab-iac/playbooks/20_dokploy.yml b/homelab-iac/playbooks/20_dokploy.yml new file mode 100644 index 0000000..0880fcb --- /dev/null +++ b/homelab-iac/playbooks/20_dokploy.yml @@ -0,0 +1,61 @@ +--- +- name: Deploy Dokploy control plane + hosts: swarm_managers + become: true + gather_facts: false + run_once: true + + tasks: + - name: Ensure Dokploy network exists + ansible.builtin.command: docker network create --driver overlay --attachable {{ dokploy_network_name }} + register: net_create + changed_when: "'already exists' not in net_create.stderr" + failed_when: + - net_create.rc != 0 + - "'already exists' not in net_create.stderr" + + - name: Ensure /etc/dokploy directory exists + ansible.builtin.file: + path: "{{ dokploy_etc_dir }}" + state: directory + mode: "0755" + + - name: Ensure Traefik dynamic directory exists + ansible.builtin.file: + path: "{{ dokploy_etc_dir }}/traefik/dynamic" + state: directory + mode: "0755" + + - name: Create or update Traefik static config + ansible.builtin.template: + src: traefik.yml.j2 + dest: "{{ dokploy_etc_dir }}/traefik/traefik.yml" + mode: "0644" + + - name: Create Dokploy dynamic config + ansible.builtin.template: + src: traefik-dokploy.yml.j2 + dest: "{{ dokploy_etc_dir }}/traefik/dynamic/dokploy.yml" + mode: "0644" + + - name: Create or update Docker secrets + ansible.builtin.shell: | + set -e + printf '%s' '{{ item.value }}' | docker secret create {{ item.name }} - 2>/dev/null || \ + printf '%s' '{{ item.value }}' | docker secret create --replace {{ item.name }} - + loop: + - { name: dokploy_postgres_password, value: "{{ vault_dokploy_postgres_password }}" } + - { name: dokploy-auth-secret, value: "{{ vault_dokploy_auth_secret }}" } + no_log: true + + - name: Render Dokploy stack file + ansible.builtin.template: + src: dokploy-stack.yml.j2 + dest: "{{ dokploy_compose_dir }}/dokploy-stack.yml" + mode: "0644" + + - name: Deploy Dokploy stack + ansible.builtin.command: | + docker stack deploy -c {{ dokploy_compose_dir }}/dokploy-stack.yml dokploy + register: stack_deploy + changed_when: "'Nothing found in stack' not in stack_deploy.stdout" diff --git a/homelab-iac/playbooks/30_supporting.yml b/homelab-iac/playbooks/30_supporting.yml new file mode 100644 index 0000000..2e55219 --- /dev/null +++ b/homelab-iac/playbooks/30_supporting.yml @@ -0,0 +1,84 @@ +--- +- name: Deploy supporting Swarm services + hosts: swarm_managers + become: true + gather_facts: false + run_once: true + + vars: + secrets_file: "{{ playbook_dir }}/../inventory/group_vars/all/secrets.sops.yml" + + tasks: + - name: Decrypt MinIO root user + ansible.builtin.shell: | + export SOPS_AGE_KEY_FILE="{{ playbook_dir }}/../.sops/age-key.txt" + sops --extract '["vault_minio_root_user"]' --decrypt {{ secrets_file | quote }} + register: _minio_root_user_raw + no_log: true + delegate_to: localhost + become: false + + - name: Decrypt MinIO root password + ansible.builtin.shell: | + export SOPS_AGE_KEY_FILE="{{ playbook_dir }}/../.sops/age-key.txt" + sops --extract '["vault_minio_root_password"]' --decrypt {{ secrets_file | quote }} + register: _minio_root_password_raw + no_log: true + delegate_to: localhost + become: false + + - name: Decrypt registry htpasswd + ansible.builtin.shell: | + export SOPS_AGE_KEY_FILE="{{ playbook_dir }}/../.sops/age-key.txt" + sops --extract '["vault_registry_htpasswd"]' --decrypt {{ secrets_file | quote }} + register: _registry_htpasswd_raw + no_log: true + delegate_to: localhost + become: false + + - name: Set decrypted secrets as facts + ansible.builtin.set_fact: + _minio_root_user: "{{ _minio_root_user_raw.stdout }}" + _minio_root_password: "{{ _minio_root_password_raw.stdout }}" + _registry_htpasswd: "{{ _registry_htpasswd_raw.stdout }}" + no_log: true + + - name: Ensure compose directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ dokploy_compose_dir }}" + - "{{ dokploy_compose_dir }}/apps" + - "{{ dokploy_compose_dir }}/files/auth" + + - name: Render supporting compose files + ansible.builtin.template: + src: "{{ playbook_dir }}/../templates/apps/{{ item }}.yml" + dest: "{{ dokploy_compose_dir }}/apps/{{ item }}.yml" + mode: "0644" + loop: + - gitea-registry + - technitium-dns + - minio + + - name: Ensure registry htpasswd file exists + ansible.builtin.copy: + content: "{{ _registry_htpasswd }}" + dest: "{{ dokploy_compose_dir }}/files/auth/registry.password" + mode: "0600" + directory_mode: "0700" + no_log: true + + - name: Deploy Gitea and registry stack + ansible.builtin.command: | + docker stack deploy -c {{ dokploy_compose_dir }}/apps/gitea-registry.yml gitea + + - name: Deploy Technitium DNS stack + ansible.builtin.command: | + docker stack deploy -c {{ dokploy_compose_dir }}/apps/technitium-dns.yml dns + + - name: Deploy MinIO stack + ansible.builtin.command: | + docker stack deploy -c {{ dokploy_compose_dir }}/apps/minio.yml minio diff --git a/homelab-iac/playbooks/90_backup.yml b/homelab-iac/playbooks/90_backup.yml new file mode 100644 index 0000000..6218a60 --- /dev/null +++ b/homelab-iac/playbooks/90_backup.yml @@ -0,0 +1,145 @@ +--- +- name: Backup Dokploy state and critical volumes + hosts: swarm_managers + become: true + gather_facts: true + run_once: true + + vars: + backup_timestamp: "{{ ansible_facts.date_time.iso8601_basic_short }}" + backup_dir: "{{ backup_base_dir }}/{{ backup_timestamp }}" + secrets_file: "{{ playbook_dir }}/../inventory/group_vars/all/secrets.sops.yml" + age_key_file: "{{ playbook_dir }}/../.sops/age-key.txt" + + tasks: + - name: Decrypt S3 access key + ansible.builtin.shell: | + export SOPS_AGE_KEY_FILE="{{ age_key_file }}" + sops --extract '["vault_backup_s3_access_key"]' --decrypt {{ secrets_file | quote }} + register: _backup_s3_access_key_raw + no_log: true + delegate_to: localhost + become: false + + - name: Decrypt S3 secret key + ansible.builtin.shell: | + export SOPS_AGE_KEY_FILE="{{ age_key_file }}" + sops --extract '["vault_backup_s3_secret_key"]' --decrypt {{ secrets_file | quote }} + register: _backup_s3_secret_key_raw + no_log: true + delegate_to: localhost + become: false + + - name: Read Age public key + ansible.builtin.shell: | + grep 'public key' "{{ age_key_file }}" | cut -d':' -f2 | tr -d ' ' + register: _age_public_key_raw + no_log: true + delegate_to: localhost + become: false + + - name: Set decrypted backup credentials + ansible.builtin.set_fact: + _backup_s3_access_key: "{{ _backup_s3_access_key_raw.stdout }}" + _backup_s3_secret_key: "{{ _backup_s3_secret_key_raw.stdout }}" + _age_public_key: "{{ _age_public_key_raw.stdout }}" + no_log: true + + - name: Ensure age is installed + ansible.builtin.apt: + name: age + state: present + when: ansible_os_family == "Debian" + + - name: Ensure backup directory exists + ansible.builtin.file: + path: "{{ backup_dir }}" + state: directory + mode: "0700" + + - name: Dump Dokploy Postgres database + ansible.builtin.shell: | + docker exec $(docker ps -q -f name=dokploy-postgres) \ + pg_dumpall -U dokploy > {{ backup_dir }}/dokploy-postgres.sql + args: + creates: "{{ backup_dir }}/dokploy-postgres.sql" + + - name: Backup Dokploy /etc directory + ansible.builtin.archive: + path: /etc/dokploy + dest: "{{ backup_dir }}/etc-dokploy.tar.gz" + mode: "0600" + + - name: Backup Traefik acme.json + ansible.builtin.copy: + src: /etc/dokploy/traefik/dynamic/acme.json + dest: "{{ backup_dir }}/acme.json" + mode: "0600" + remote_src: true + ignore_errors: true + + - name: Backup critical named volumes + ansible.builtin.shell: | + set -e + for vol in dokploy-postgres-database redis-data-volume dokploy-docker-config gitea-giteasqlite-bhymqw_gitea-data gitea-registry-vdftrt_registry-data dns-technitum-6ojgo2_config-dns; do + docker run --rm -v "${vol}:/source:ro" -v "{{ backup_dir }}:/backup" alpine \ + tar czf "/backup/vol-${vol}.tar.gz" -C /source . + done + args: + creates: "{{ backup_dir }}/vol-dokploy-postgres-database.tar.gz" + + - name: Encrypt backup bundle with Age + ansible.builtin.shell: | + tar czf - -C {{ backup_base_dir }} {{ backup_timestamp }} | \ + age -r {{ _age_public_key }} -o {{ backup_dir }}.tar.gz.age + args: + creates: "{{ backup_dir }}.tar.gz.age" + when: _age_public_key is defined + + - name: Clean old unencrypted backup tree + ansible.builtin.file: + path: "{{ backup_dir }}" + state: absent + when: _age_public_key is defined + + - name: Ensure rclone is installed + ansible.builtin.apt: + name: rclone + state: present + when: backup_s3_enabled | bool + tags: + - upload + + - name: Upload encrypted backup to MinIO S3 + ansible.builtin.shell: | + set -e + export RCLONE_CONFIG_S3_TYPE=s3 + export RCLONE_CONFIG_S3_PROVIDER=Minio + export RCLONE_CONFIG_S3_ENDPOINT={{ backup_s3_endpoint }} + export RCLONE_CONFIG_S3_ACCESS_KEY_ID={{ _backup_s3_access_key }} + export RCLONE_CONFIG_S3_SECRET_ACCESS_KEY={{ _backup_s3_secret_key }} + export RCLONE_CONFIG_S3_REGION={{ backup_s3_region }} + export RCLONE_CONFIG_S3_NO_CHECK_BUCKET=true + export RCLONE_CONFIG_S3_ACL= + rclone copy "{{ backup_dir }}.tar.gz.age" "s3:{{ backup_s3_bucket }}/{{ backup_s3_path }}/" + args: + creates: "{{ backup_dir }}.s3-uploaded" + when: backup_s3_enabled | bool + no_log: true + tags: + - upload + + - name: Mark S3 upload complete + ansible.builtin.file: + path: "{{ backup_dir }}.s3-uploaded" + state: touch + when: backup_s3_enabled | bool + tags: + - upload + + - name: Prune local backups older than retention period + ansible.builtin.shell: | + find {{ backup_base_dir }} -maxdepth 1 -type f -name '*.age' -mtime +{{ backup_retention_days }} -delete + changed_when: true + tags: + - upload diff --git a/homelab-iac/playbooks/91_export_dokploy.yml b/homelab-iac/playbooks/91_export_dokploy.yml new file mode 100644 index 0000000..dcf9e81 --- /dev/null +++ b/homelab-iac/playbooks/91_export_dokploy.yml @@ -0,0 +1,86 @@ +--- +- name: Export Dokploy configuration metadata from Postgres + hosts: swarm_managers + become: true + gather_facts: true + run_once: true + + vars: + export_dir: "{{ dokploy_compose_dir }}/dokploy-export" + export_timestamp: "{{ ansible_facts.date_time.iso8601_basic_short }}" + local_export_dir: "{{ playbook_dir }}/../exports" + + tasks: + - name: Ensure export directory exists on manager + ansible.builtin.file: + path: "{{ export_dir }}" + state: directory + mode: "0700" + + - name: Export key Dokploy tables as JSON + ansible.builtin.shell: | + set -e + docker exec $(docker ps -q -f name=dokploy-postgres) \ + psql -U dokploy -t -A -F"," -c "SELECT json_agg(t) FROM (SELECT * FROM \"{{ item }}\") t" \ + > "{{ export_dir }}/{{ item | regex_replace('-', '_') }}.json" + loop: + - application + - compose + - domain + - mount + - redirect + - security + - port + - server + - registry + - destination + - backup + - schedule + - notification + - ssh-key + - git_provider + - github + - gitlab + - gitea + - bitbucket + - environment + - project + - user + - organization + - apikey + + - name: Fetch exports to control machine + ansible.builtin.fetch: + src: "{{ export_dir }}/{{ item | regex_replace('-', '_') }}.json" + dest: "{{ local_export_dir }}/{{ export_timestamp }}/{{ item | regex_replace('-', '_') }}.json" + flat: true + loop: + - application + - compose + - domain + - mount + - redirect + - security + - port + - server + - registry + - destination + - backup + - schedule + - notification + - ssh-key + - git_provider + - github + - gitlab + - gitea + - bitbucket + - environment + - project + - user + - organization + - apikey + + - name: Clean old exports on manager + ansible.builtin.file: + path: "{{ export_dir }}" + state: absent diff --git a/homelab-iac/requirements.yml b/homelab-iac/requirements.yml new file mode 100644 index 0000000..212649b --- /dev/null +++ b/homelab-iac/requirements.yml @@ -0,0 +1,3 @@ +collections: + - name: community.docker + - name: ansible.posix diff --git a/homelab-iac/scripts/init-age.sh b/homelab-iac/scripts/init-age.sh new file mode 100755 index 0000000..878dc6d --- /dev/null +++ b/homelab-iac/scripts/init-age.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +KEYS_DIR="${REPO_ROOT}/.sops" +KEY_FILE="${KEYS_DIR}/age-key.txt" + +if [ -f "$KEY_FILE" ]; then + echo "Age key already exists at ${KEY_FILE}" + echo "Public key: $(grep 'public key' "$KEY_FILE" | cut -d':' -f2 | tr -d ' ')" + exit 0 +fi + +mkdir -p "$KEYS_DIR" +echo "Generating Age key..." +age-keygen -o "$KEY_FILE" + +echo "" +echo "Key written to ${KEY_FILE}" +echo "BACK THIS FILE UP OFFLINE. If it is lost, all encrypted secrets are unrecoverable." +echo "" +echo "Add the following to .sops.yaml in the repo root:" +echo "creation_rules:" +echo " - path_regex: \"inventory/group_vars/.*\\.sops\\.yml$\"" +echo " age: \"$(grep 'public key' "$KEY_FILE" | cut -d':' -f2 | tr -d ' ')\"" diff --git a/homelab-iac/templates/apps/gitea-registry.yml b/homelab-iac/templates/apps/gitea-registry.yml new file mode 100644 index 0000000..66b92b5 --- /dev/null +++ b/homelab-iac/templates/apps/gitea-registry.yml @@ -0,0 +1,67 @@ +version: "3.9" + +services: + gitea: + image: docker.gitea.com/gitea:1.24.4 + restart: unless-stopped + environment: + - USER_UID=1000 + - USER_GID=1000 + volumes: + - gitea-data:/data + expose: + - "3000" + ports: + - "2222:22" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/"] + interval: 15s + timeout: 5s + retries: 10 + networks: + - gitea-giteasqlite + labels: + - traefik.enable=true + - traefik.http.routers.gitea-web.rule=Host(`gitea.bendtstudio.com`) + - traefik.http.routers.gitea-web.entrypoints=web + - traefik.http.routers.gitea-web.middlewares=redirect-to-https@file + - traefik.http.routers.gitea-websecure.rule=Host(`gitea.bendtstudio.com`) + - traefik.http.routers.gitea-websecure.entrypoints=websecure + - traefik.http.routers.gitea-websecure.tls.certresolver=letsencrypt + - traefik.http.services.gitea.loadbalancer.server.port=3000 + + registry: + image: registry:2 + restart: always + environment: + REGISTRY_STORAGE_DELETE_ENABLED: "true" + REGISTRY_HEALTH_STORAGEDRIVER_ENABLED: "false" + REGISTRY_HTTP_SECRET: "{{ vault_registry_http_secret }}" + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm" + REGISTRY_AUTH_HTPASSWD_PATH: /auth/registry.password + volumes: + - ./files/auth/registry.password:/auth/registry.password:ro + - registry-data:/var/lib/registry + networks: + - gitea-registry + - dokploy-network + labels: + - traefik.enable=true + - traefik.http.routers.registry-web.rule=Host(`registry.lan`) + - traefik.http.routers.registry-web.entrypoints=web + - traefik.http.routers.registry-web.middlewares=redirect-to-https@file + - traefik.http.routers.registry-websecure.rule=Host(`registry.lan`) + - traefik.http.routers.registry-websecure.entrypoints=websecure + - traefik.http.routers.registry-websecure.tls.certresolver=letsencrypt + - traefik.http.services.registry.loadbalancer.server.port=5000 + +volumes: + gitea-data: + registry-data: + +networks: + gitea-giteasqlite: + gitea-registry: + dokploy-network: + external: true diff --git a/homelab-iac/templates/apps/minio.yml b/homelab-iac/templates/apps/minio.yml new file mode 100644 index 0000000..341e253 --- /dev/null +++ b/homelab-iac/templates/apps/minio.yml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + deploy: + replicas: 1 + placement: + constraints: + - node.labels.type == nas + restart_policy: + condition: any + environment: + MINIO_ROOT_USER: "{{ _minio_root_user }}" + MINIO_ROOT_PASSWORD: "{{ _minio_root_password }}" + volumes: + - "{{ minio_data_dir }}:/data" + networks: + - dokploy-network + ports: + - target: 9000 + published: 9000 + mode: host + - target: 9001 + published: 9001 + mode: host + labels: + - traefik.enable=true + - traefik.http.routers.minio-web.rule=Host(`minio.bendtstudio.com`) + - traefik.http.routers.minio-web.entrypoints=web + - traefik.http.routers.minio-web.middlewares=redirect-to-https@file + - traefik.http.routers.minio-websecure.rule=Host(`minio.bendtstudio.com`) + - traefik.http.routers.minio-websecure.entrypoints=websecure + - traefik.http.routers.minio-websecure.tls.certresolver=letsencrypt + - traefik.http.services.minio.loadbalancer.server.port=9001 + +networks: + dokploy-network: + external: true diff --git a/homelab-iac/templates/apps/technitium-dns.yml b/homelab-iac/templates/apps/technitium-dns.yml new file mode 100644 index 0000000..bbd5927 --- /dev/null +++ b/homelab-iac/templates/apps/technitium-dns.yml @@ -0,0 +1,45 @@ +version: "3.9" + +services: + technitium-dns: + image: technitium/dns-server:latest + container_name: technitium-dns + restart: unless-stopped + ports: + - "53:53/tcp" + - "53:53/udp" + - "5380:5380/tcp" + - "53443:53443/tcp" + volumes: + - config-dns:/etc/dns + environment: + DNS_SERVER_DOMAIN: dns.bendtstudio.com + DNS_SERVER_ADMIN_PASSWORD: "{{ vault_technitium_admin_password }}" + TZ: America/New_York + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == tpi-n1 + restart_policy: + condition: any + networks: + - dokploy-network + labels: + - traefik.enable=true + - traefik.docker.network=dokploy-network + - traefik.http.routers.dns-web.rule=Host(`dns.bendtstudio.com`) + - traefik.http.routers.dns-web.entrypoints=web + - traefik.http.routers.dns-web.middlewares=redirect-to-https@file + - traefik.http.routers.dns-websecure.rule=Host(`dns.bendtstudio.com`) + - traefik.http.routers.dns-websecure.entrypoints=websecure + - traefik.http.routers.dns-websecure.tls.certresolver=letsencrypt + - traefik.http.services.dns.loadbalancer.server.port=5380 + +volumes: + config-dns: + +networks: + dokploy-network: + external: true diff --git a/homelab-iac/templates/docker-daemon.json.j2 b/homelab-iac/templates/docker-daemon.json.j2 new file mode 100644 index 0000000..b0bec66 --- /dev/null +++ b/homelab-iac/templates/docker-daemon.json.j2 @@ -0,0 +1,5 @@ +{ + "data-root": "{{ docker_data_root }}", + "dns": {{ ([homelab_dns_server] + fallback_dns) | to_json }}, + "insecure-registries": {{ insecure_registries | to_json }} +} diff --git a/homelab-iac/templates/dokploy-stack.yml.j2 b/homelab-iac/templates/dokploy-stack.yml.j2 new file mode 100644 index 0000000..6e6b3fb --- /dev/null +++ b/homelab-iac/templates/dokploy-stack.yml.j2 @@ -0,0 +1,111 @@ +version: "3.9" + +services: + dokploy: + image: dokploy/dokploy:{{ dokploy_version }} + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + restart_policy: + condition: any + environment: + - ADVERTISE_ADDR={{ swarm_advertise_addr }} + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + - BETTER_AUTH_SECRET_FILE=/run/secrets/dokploy-auth-secret + volumes: + - /etc/dokploy:/etc/dokploy + - /var/run/docker.sock:/var/run/docker.sock + - dokploy-docker-config:/root/.docker + secrets: + - postgres_password + - dokploy-auth-secret + networks: + - dokploy-network + ports: + - target: 3000 + published: {{ dokploy_ui_port }} + mode: host + + dokploy-postgres: + image: postgres:{{ dokploy_postgres_version }} + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + restart_policy: + condition: any + environment: + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + - POSTGRES_USER=dokploy + - POSTGRES_DB=dokploy + - PGDATA=/var/lib/postgresql/data + volumes: + - dokploy-postgres-database:/var/lib/postgresql/data + secrets: + - postgres_password + networks: + - dokploy-network + + dokploy-redis: + image: redis:{{ dokploy_redis_version }} + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + restart_policy: + condition: any + volumes: + - redis-data-volume:/data + networks: + - dokploy-network + + dokploy-traefik: + image: traefik:{{ dokploy_traefik_version }} + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + restart_policy: + condition: any + command: + - --configFile=/etc/traefik/traefik.yml + volumes: + - /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - dokploy-network + ports: + - target: 80 + published: 80 + mode: host + - target: 443 + published: 443 + mode: host + - target: 443 + published: 443 + protocol: udp + mode: host + - target: 8080 + published: {{ dokploy_traefik_dashboard_port }} + mode: host + +secrets: + postgres_password: + external: true + dokploy-auth-secret: + external: true + +volumes: + dokploy-docker-config: + dokploy-postgres-database: + redis-data-volume: + +networks: + dokploy-network: + external: true diff --git a/homelab-iac/templates/traefik-dokploy.yml.j2 b/homelab-iac/templates/traefik-dokploy.yml.j2 new file mode 100644 index 0000000..45f2260 --- /dev/null +++ b/homelab-iac/templates/traefik-dokploy.yml.j2 @@ -0,0 +1,21 @@ +http: + middlewares: + redirect-to-https: + redirectScheme: + scheme: https + permanent: true + + routers: + dokploy-router-app: + rule: Host(`dokploy.docker.localhost`) && PathPrefix(`/`) + service: dokploy-service-app + entryPoints: + - web + middlewares: [] + + services: + dokploy-service-app: + loadBalancer: + servers: + - url: http://dokploy:3000 + passHostHeader: true diff --git a/homelab-iac/templates/traefik.yml.j2 b/homelab-iac/templates/traefik.yml.j2 new file mode 100644 index 0000000..9deda89 --- /dev/null +++ b/homelab-iac/templates/traefik.yml.j2 @@ -0,0 +1,41 @@ +global: + sendAnonymousUsage: false + +providers: + swarm: + exposedByDefault: false + watch: true + docker: + exposedByDefault: false + watch: true + network: {{ dokploy_network_name }} + file: + directory: /etc/dokploy/traefik/dynamic + watch: true + +entryPoints: + web: + address: :80 + websecure: + address: :443 + http3: + advertisedPort: 443 + http: + tls: + certResolver: letsencrypt + +api: + insecure: true + +certificatesResolvers: + letsencrypt: + acme: + email: {{ letsencrypt_email }} + storage: /etc/dokploy/traefik/dynamic/acme.json + httpChallenge: + entryPoint: web + +accessLog: + filePath: /etc/dokploy/traefik/dynamic/access.log + format: json + bufferingSize: 100