first commit
This commit is contained in:
13
homelab-iac/.gitignore
vendored
Normal file
13
homelab-iac/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
.sops/
|
||||
*.age
|
||||
*.key
|
||||
*.pem
|
||||
*.sql
|
||||
*.tar.gz
|
||||
acme.json
|
||||
secrets.sops.yml.dec
|
||||
exports/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.vscode/
|
||||
.idea/
|
||||
5
homelab-iac/.sops.yaml
Normal file
5
homelab-iac/.sops.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
creation_rules:
|
||||
- path_regex: "inventory/group_vars/.*\\.sops\\.yml$"
|
||||
age: "age1g759cwnlt6nq5u87jcs6m76fz3spw5awcwrchuck8ythxpf4g9rqarhy9y"
|
||||
- path_regex: "files/.*\\.sops\\.*$"
|
||||
age: "age1g759cwnlt6nq5u87jcs6m76fz3spw5awcwrchuck8ythxpf4g9rqarhy9y"
|
||||
49
homelab-iac/Makefile
Normal file
49
homelab-iac/Makefile
Normal file
@@ -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
|
||||
57
homelab-iac/README.md
Normal file
57
homelab-iac/README.md
Normal file
@@ -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)
|
||||
58
homelab-iac/docs/maintenance.md
Normal file
58
homelab-iac/docs/maintenance.md
Normal file
@@ -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.
|
||||
22
homelab-iac/docs/runbook-add-node.md
Normal file
22
homelab-iac/docs/runbook-add-node.md
Normal file
@@ -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).
|
||||
63
homelab-iac/docs/runbook-restore-dokploy.md
Normal file
63
homelab-iac/docs/runbook-restore-dokploy.md
Normal file
@@ -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.<taskid>
|
||||
```
|
||||
|
||||
## 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.
|
||||
51
homelab-iac/inventory/group_vars/all/main.yml
Normal file
51
homelab-iac/inventory/group_vars/all/main.yml
Normal file
@@ -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: ""
|
||||
32
homelab-iac/inventory/group_vars/all/secrets.sops.yml
Normal file
32
homelab-iac/inventory/group_vars/all/secrets.sops.yml
Normal file
@@ -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
|
||||
84
homelab-iac/inventory/production.yml
Normal file
84
homelab-iac/inventory/production.yml
Normal file
@@ -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
|
||||
161
homelab-iac/playbooks/00_bootstrap_nodes.yml
Normal file
161
homelab-iac/playbooks/00_bootstrap_nodes.yml
Normal file
@@ -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
|
||||
68
homelab-iac/playbooks/10_swarm_init.yml
Normal file
68
homelab-iac/playbooks/10_swarm_init.yml
Normal file
@@ -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
|
||||
61
homelab-iac/playbooks/20_dokploy.yml
Normal file
61
homelab-iac/playbooks/20_dokploy.yml
Normal file
@@ -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"
|
||||
84
homelab-iac/playbooks/30_supporting.yml
Normal file
84
homelab-iac/playbooks/30_supporting.yml
Normal file
@@ -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
|
||||
145
homelab-iac/playbooks/90_backup.yml
Normal file
145
homelab-iac/playbooks/90_backup.yml
Normal file
@@ -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
|
||||
86
homelab-iac/playbooks/91_export_dokploy.yml
Normal file
86
homelab-iac/playbooks/91_export_dokploy.yml
Normal file
@@ -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
|
||||
3
homelab-iac/requirements.yml
Normal file
3
homelab-iac/requirements.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
collections:
|
||||
- name: community.docker
|
||||
- name: ansible.posix
|
||||
25
homelab-iac/scripts/init-age.sh
Executable file
25
homelab-iac/scripts/init-age.sh
Executable file
@@ -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 ' ')\""
|
||||
67
homelab-iac/templates/apps/gitea-registry.yml
Normal file
67
homelab-iac/templates/apps/gitea-registry.yml
Normal file
@@ -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
|
||||
40
homelab-iac/templates/apps/minio.yml
Normal file
40
homelab-iac/templates/apps/minio.yml
Normal file
@@ -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
|
||||
45
homelab-iac/templates/apps/technitium-dns.yml
Normal file
45
homelab-iac/templates/apps/technitium-dns.yml
Normal file
@@ -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
|
||||
5
homelab-iac/templates/docker-daemon.json.j2
Normal file
5
homelab-iac/templates/docker-daemon.json.j2
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"data-root": "{{ docker_data_root }}",
|
||||
"dns": {{ ([homelab_dns_server] + fallback_dns) | to_json }},
|
||||
"insecure-registries": {{ insecure_registries | to_json }}
|
||||
}
|
||||
111
homelab-iac/templates/dokploy-stack.yml.j2
Normal file
111
homelab-iac/templates/dokploy-stack.yml.j2
Normal file
@@ -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
|
||||
21
homelab-iac/templates/traefik-dokploy.yml.j2
Normal file
21
homelab-iac/templates/traefik-dokploy.yml.j2
Normal file
@@ -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
|
||||
41
homelab-iac/templates/traefik.yml.j2
Normal file
41
homelab-iac/templates/traefik.yml.j2
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user