Archived
Two new NixOS Proxmox VMs (VMIDs 202/203) forming a dual-manager Docker
Swarm on dedicated vmbr3 (192.168.30.0/24, VLAN 30) for gossip and VXLAN,
with NFS via the storage-client network (vmbr2) from the existing HA cluster.
- nixos/variables.nix: add ha-docker IP/interface/port vars and swarm CIDR
- nixos/modules/build-types/ha-docker.nix: new build type — Docker 29,
NFS mounts, beszel-agent, health monitoring, swarm firewall rules with
checkReversePath = "loose" for VXLAN routing mesh
- nixos/hosts/ha-docker-{1,2}/host.nix: per-host identity — three NICs
(LAN, storage, swarm), IPA dyndns pinned to LAN interface
- nixos/flake.nix: add proxmox-ha-docker-{1,2} targets; build-validated
with nix build --dry-run (169 derivations, no errors)
- nixos/docs/ip-addressing.md: document VLAN 30 / swarm.home zone,
ha-docker IP allocations across all three subnets
- nixos/scripts/docker-swarm/deploy.sh: 10-phase lifecycle script
(bridge, keys, IPA, VMs, swarm init, DNS, verify); modelled on
scripts/ha/deploy.sh with --destroy mode
- nixos/docs/internal/docker-swarm-cutover.md: service-by-service
migration guide covering Traefik log rotation, Nextcloud cron sidecar,
docker-health-to-gotify swarm awareness updates, Passbolt/Gitea steps,
DNS cutover, and CT 105 decommission checklist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DASH15okNvWeY1rVJmyJoJ
331 lines
11 KiB
Markdown
331 lines
11 KiB
Markdown
# Docker Swarm Cutover Plan
|
||
|
||
Migration guide for moving containerised services from the existing single-host
|
||
Docker LXC container (CT 105, `docker.sweet.home`, 192.168.2.225) to the new
|
||
Docker Swarm cluster (`ha-docker-1` / `ha-docker-2`, 192.168.2.230–231).
|
||
|
||
CT 105 stays running throughout. Services migrate one stack at a time.
|
||
Roll back any stack by restarting it on CT 105 if anything goes wrong.
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
- Swarm cluster deployed and healthy (`scripts/docker-swarm/deploy.sh`).
|
||
- Both nodes show `Ready / Active / Manager` in `docker node ls`.
|
||
- NFS mounts healthy on both swarm nodes (`/mnt/docker/config`, `/mnt/docker/databases`, `/mnt/docker/volumes`).
|
||
- Access to FreeIPA DNS admin to update A records during cutover.
|
||
|
||
---
|
||
|
||
## 1. Traefik — switch to Docker log rotation
|
||
|
||
**Current state (CT 105):** Traefik writes access logs to the NFS volume at
|
||
`/mnt/docker/volumes/traefik-data/logs/`. `modules/traefik/rotate-logs.nix`
|
||
rotates those files via `logrotate`.
|
||
|
||
**Swarm approach:** Remove file-based access logging from Traefik's static
|
||
config and rely on Docker's json-file log driver with built-in rotation.
|
||
Traefik container logs (including access events) then live under
|
||
`/var/lib/docker/containers/<id>/` on the node running Traefik.
|
||
|
||
### Steps
|
||
|
||
**1a.** In the Traefik stack definition, add logging config to the service:
|
||
|
||
```yaml
|
||
services:
|
||
traefik:
|
||
logging:
|
||
driver: "json-file"
|
||
options:
|
||
max-size: "100m"
|
||
max-file: "20"
|
||
```
|
||
|
||
**1b.** In `traefik.yml` (Traefik's static config), remove the `accessLog`
|
||
file path if present. To keep structured access logs, use Traefik's
|
||
`accessLog.format: json` with no `filePath` — logs then go to stdout and are
|
||
captured by the json-file driver above.
|
||
|
||
**1c.** Deploy Traefik to the swarm:
|
||
|
||
```bash
|
||
# On either swarm manager:
|
||
docker stack deploy -c /mnt/docker/config/traefik/docker-compose.yml traefik
|
||
```
|
||
|
||
Traefik should be deployed as a **global mode** service so it runs on all
|
||
swarm nodes and handles ingress on whichever node a request arrives at:
|
||
|
||
```yaml
|
||
services:
|
||
traefik:
|
||
deploy:
|
||
mode: global
|
||
placement:
|
||
constraints:
|
||
- node.role == manager
|
||
```
|
||
|
||
**1d.** After confirming Traefik works on the swarm, remove
|
||
`traefik/rotate-logs.nix` from the `docker` build type in
|
||
`modules/build-types/docker.nix` and rebuild CT 105.
|
||
|
||
**DNS:** Update `docker.sweet.home` and any service FQDNs that point at
|
||
192.168.2.225 to a swarm VIP or round-robin A records once Traefik is running
|
||
on the swarm. See section 8 (DNS cutover).
|
||
|
||
---
|
||
|
||
## 2. Nextcloud — migrate cron job to sidecar container
|
||
|
||
**Current state (CT 105):** `modules/docker/nextcloud-cron-job.nix` runs a
|
||
systemd timer every 5 minutes that calls:
|
||
```bash
|
||
docker exec nextcloud-webapp php ./cron.php
|
||
```
|
||
|
||
**Swarm problem:** `docker exec` only works against the local daemon. If
|
||
Nextcloud is scheduled on the other swarm node, the exec fails silently and
|
||
cron never runs.
|
||
|
||
**Swarm approach:** Add a `nextcloud-cron` sidecar container to the Nextcloud
|
||
stack definition, pinned to the same node as the main Nextcloud container via
|
||
placement constraints.
|
||
|
||
### Steps
|
||
|
||
**2a.** Choose which swarm node will host Nextcloud (e.g. `ha-docker-1`).
|
||
Label that node:
|
||
|
||
```bash
|
||
# On either swarm manager:
|
||
docker node update --label-add nextcloud=true ha-docker-1
|
||
```
|
||
|
||
**2b.** In the Nextcloud stack compose file, add the sidecar and pin both
|
||
services to the labelled node:
|
||
|
||
```yaml
|
||
services:
|
||
nextcloud-webapp:
|
||
image: nextcloud:production # pin same version as CT 105
|
||
deploy:
|
||
replicas: 1
|
||
placement:
|
||
constraints:
|
||
- node.labels.nextcloud == true
|
||
# ... existing volumes, env, networks ...
|
||
|
||
nextcloud-cron:
|
||
image: nextcloud:production # same image, different entrypoint
|
||
entrypoint: /cron.sh
|
||
deploy:
|
||
replicas: 1
|
||
placement:
|
||
constraints:
|
||
- node.labels.nextcloud == true # must co-locate with webapp
|
||
volumes:
|
||
# Same data volume as nextcloud-webapp so cron sees the same files.
|
||
- nextcloud-data:/var/www/html
|
||
# No ports exposed — cron only runs PHP inside the container.
|
||
```
|
||
|
||
`/cron.sh` is Nextcloud's built-in cron entrypoint. It runs
|
||
`php -f /var/www/html/cron.php` in a loop, sleeping for 5 minutes between
|
||
runs — identical to the current systemd timer.
|
||
|
||
**2c.** Migrate Nextcloud's data volume to the swarm:
|
||
|
||
```
|
||
/mnt/docker/volumes/nextcloud-data/ → already on NFS, no migration needed
|
||
/mnt/docker/databases/nextcloud/ → already on NFS, no migration needed
|
||
```
|
||
|
||
The NFS paths are identical on the swarm nodes (`mount-data.nix` mounts the
|
||
same shares from the same VIP). Stop Nextcloud on CT 105, deploy on the
|
||
swarm, confirm it starts cleanly.
|
||
|
||
**2d.** Remove `nextcloud-cron-job.nix` from `modules/build-types/docker.nix`
|
||
and rebuild CT 105 after confirming Nextcloud works on the swarm.
|
||
|
||
---
|
||
|
||
## 3. docker-health-to-gotify — update for swarm awareness
|
||
|
||
**Current state (CT 105):** The script at
|
||
`/home/nixos/docker/monitoring/gotify/docker-health-to-gotify.sh` runs every
|
||
minute, calls `docker ps --filter health=unhealthy`, and notifies Gotify.
|
||
|
||
**Swarm behaviour:** The same script runs on both swarm nodes independently,
|
||
each monitoring its own local Docker daemon. This gives per-node coverage
|
||
across the swarm.
|
||
|
||
**Changes needed in the script** (edit the copy on the NFS volume — it takes
|
||
effect on both nodes simultaneously on the next timer fire):
|
||
|
||
### 3a. Strip the Swarm task suffix from service names
|
||
|
||
In swarm mode, `docker ps --format '{{.Names}}'` returns names like
|
||
`nextcloud-webapp.1.abc123xyz`. The notification should show `nextcloud-webapp`,
|
||
not the full task name.
|
||
|
||
```bash
|
||
# Before:
|
||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ...)
|
||
|
||
# After:
|
||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' ... | cut -d. -f1)
|
||
```
|
||
|
||
### 3b. Include the reporting node in the Gotify message
|
||
|
||
Add `$(hostname)` to the notification payload so you know which swarm node
|
||
detected the problem:
|
||
|
||
```bash
|
||
MESSAGE="[$(hostname)] ${CONTAINER_NAME} is unhealthy"
|
||
```
|
||
|
||
### 3c. Extend to catch swarm service replica failures
|
||
|
||
`docker ps` only shows what's running locally. If a service has zero healthy
|
||
replicas (task crash-looping) it may not show up on either node's `docker ps`
|
||
at the same moment. Add a swarm-level check:
|
||
|
||
```bash
|
||
# Run only on managers (both ha-docker nodes are managers):
|
||
if docker info --format '{{.Swarm.ControlAvailable}}' 2>/dev/null | grep -q true; then
|
||
# Find services where running replicas < desired replicas
|
||
docker service ls --format '{{.Name}}\t{{.Replicas}}' | \
|
||
awk -F'\t' '$2 !~ /^[0-9]+\/[0-9]+$/ || split($2,a,"/") && a[1] < a[2] { print $1, $2 }' | \
|
||
while read -r svc_name replicas; do
|
||
# Send Gotify notification for degraded service
|
||
curl -s -X POST "${GOTIFY_URL}/message" \
|
||
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||
-d "title=Swarm service degraded" \
|
||
-d "message=[$(hostname)] ${svc_name}: ${replicas} replicas"
|
||
done
|
||
fi
|
||
```
|
||
|
||
This catches the case where a service's desired replicas are not running
|
||
(e.g. OOM kill, image pull failure) — a failure mode that doesn't produce a
|
||
Docker health event on any node.
|
||
|
||
---
|
||
|
||
## 4. Passbolt migration
|
||
|
||
Passbolt has strict data integrity requirements. Migrate with care:
|
||
|
||
1. **Backup first** — `docker exec passbolt-webapp php /usr/share/php/passbolt/bin/cake passbolt export_keys` and a database dump.
|
||
2. Database is on NFS (`/mnt/docker/databases/passbolt/`) — no data copy needed.
|
||
3. Pin Passbolt to a specific node: `docker node update --label-add passbolt=true ha-docker-1`
|
||
4. Add placement constraint `node.labels.passbolt == true` to the Passbolt stack.
|
||
5. Stop on CT 105, deploy on swarm, verify login works.
|
||
6. Test email delivery and 2FA.
|
||
|
||
---
|
||
|
||
## 5. Gitea migration
|
||
|
||
Gitea's data directory is on NFS (`/mnt/docker/volumes/gitea-data/`).
|
||
|
||
1. Stop Gitea on CT 105: `docker stop gitea`
|
||
2. Deploy to swarm with placement constraint (pin to `ha-docker-1` initially).
|
||
3. Verify web UI and SSH clone/push work.
|
||
4. Update DNS: `gitea.lan.ddnsgeek.com` → swarm Traefik endpoint.
|
||
5. Update the flake remote URL in `variables.nix` (`giteaDomain`) if the address changes.
|
||
|
||
---
|
||
|
||
## 6. Other services
|
||
|
||
Deploy remaining services (Grafana, InfluxDB, NodeRed, Prometheus, etc.)
|
||
as swarm stacks. Most have no special migration concern — they use NFS
|
||
volumes already on the shared storage.
|
||
|
||
Services with stateful databases (PostgreSQL, MariaDB) should follow the
|
||
pattern: stop on CT 105, confirm NFS database directory is intact, deploy on
|
||
swarm, verify.
|
||
|
||
---
|
||
|
||
## 7. Monitoring — Beszel
|
||
|
||
The Beszel hub runs on CT 105 (`docker.sweet.home:8090`). Both swarm nodes
|
||
run `beszel-agent` (from `modules/beszel/enable-agent.nix`), pointing at the
|
||
existing hub URL.
|
||
|
||
No migration needed for Beszel itself during the container migration. Once
|
||
all services are on the swarm, you may wish to move the Beszel hub too (as a
|
||
swarm service with a placement constraint) but this is optional.
|
||
|
||
---
|
||
|
||
## 8. DNS cutover
|
||
|
||
When a service is confirmed working on the swarm, update the FreeIPA DNS
|
||
A record from the CT 105 IP (192.168.2.225) to a swarm node IP or, when a
|
||
shared Traefik frontend is in place, to a round-robin record across both nodes.
|
||
|
||
**Recommended approach — Traefik as the single entry point:**
|
||
|
||
```
|
||
service.lan.ddnsgeek.com → Traefik on swarm (global mode)
|
||
docker.sweet.home → keep as 192.168.2.225 (CT 105) until fully decommissioned
|
||
```
|
||
|
||
For LAN-only services using `*.sweet.home` names, update FreeIPA directly:
|
||
|
||
```bash
|
||
# On domain-controller (or via SSH):
|
||
ipa dnsrecord-mod sweet.home nextcloud --a-rec=192.168.2.230
|
||
# Add 192.168.2.231 as a second A record for round-robin (optional):
|
||
ipa dnsrecord-add sweet.home nextcloud --a-rec=192.168.2.231
|
||
```
|
||
|
||
Services behind Traefik don't need their own DNS updates — only Traefik's
|
||
own entry point IPs need to change.
|
||
|
||
---
|
||
|
||
## 9. NixOS cleanup — CT 105
|
||
|
||
Once all services are migrated:
|
||
|
||
**Remove from `modules/build-types/docker.nix`:**
|
||
- `../docker/nextcloud-cron-job.nix` — replaced by sidecar container
|
||
- `../traefik/rotate-logs.nix` — replaced by Docker log driver
|
||
|
||
**Keep in `modules/build-types/docker.nix` until CT 105 is decommissioned:**
|
||
- `../docker/docker-health-to-gotify.nix` — still monitors CT 105's own daemon
|
||
- Everything else
|
||
|
||
**When decommissioning CT 105:**
|
||
1. Confirm all NFS volumes are in use only by swarm services (not CT 105).
|
||
2. Stop CT 105: `pct stop 105` on pve1.
|
||
3. Archive/remove the `lxc-docker` and `proxmox-docker` targets from `flake.nix`.
|
||
4. Remove `hosts/docker/`, `modules/build-types/docker.nix`, and `modules/docker/`.
|
||
5. Update `variables.nix` to remove `dockerIp`, `dockerStorageIp`, `dockerHost`
|
||
(or reassign `dockerHost` to point at a swarm node for Beszel hub resolution).
|
||
|
||
---
|
||
|
||
## Rollback
|
||
|
||
Any stack can be rolled back to CT 105 independently:
|
||
|
||
```bash
|
||
# On CT 105:
|
||
docker start <service-name>
|
||
# Update DNS A record back to 192.168.2.225
|
||
ipa dnsrecord-mod sweet.home <service> --a-rec=192.168.2.225
|
||
```
|
||
|
||
CT 105 remains running throughout the cutover. Only decommission it after
|
||
every service is confirmed stable on the swarm and you have run one full
|
||
backup cycle from the new hosts.
|