From 4ec30ca3e634f71e5016ee7f8d192890536daad6 Mon Sep 17 00:00:00 2001 From: S Date: Sun, 1 Mar 2026 11:06:53 -0500 Subject: [PATCH] =?UTF-8?q?docs:=20update=20PLAN.md=20=E2=80=94=20Nginx?= =?UTF-8?q?=E2=86=92Caddy,=20SSL=5FMODE=E2=86=92TLS=5FMODE,=20port?= =?UTF-8?q?=E2=86=92IP=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added note that plan describes original architecture with diffs noted - Architecture table: Nginx+Certbot→Caddy+Cloudflare DNS-01 - File structure: nginx-gitea.conf.tpl→Caddyfile.tpl + caddy compose - Variable table: NGINX_*/SSL_MODE/SSL_EMAIL→TLS_MODE/CADDY_*/CLOUDFLARE_* - Preflight checks: port checks→container IP availability, Nginx→Caddy path - Phase 8: complete rewrite from 10-step Nginx flow to 6-step Caddy flow - Template section: replaced nginx template spec with Caddy template spec - Removed stale port variables from "Not checked" list Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 149 +++++++++++++++++++++++++------------------------------- 1 file changed, 66 insertions(+), 83 deletions(-) diff --git a/PLAN.md b/PLAN.md index 94374a2..7a4b410 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,7 +1,9 @@ # Gitea Migration Toolkit — Implementation Plan +> **Note**: This is the original implementation plan. Some sections describe the initial Nginx/SSL_MODE/3-repo architecture that has since been replaced by Caddy/TLS_MODE/dynamic repos with macvlan networking. See README.md and .env.example for the current architecture. + ## Context -Migrating 3 GitHub repos to self-hosted Gitea (Unraid primary, Fedora backup mirror, GitHub as push mirror). All automation runs from MacBook, SSHing into remote machines. Scripts must be idempotent, .env-driven, with preflight + post-checks + teardown per phase. +Migrating GitHub repos to self-hosted Gitea (Unraid primary, Fedora backup mirror, GitHub as push mirror). All automation runs from MacBook, SSHing into remote machines. Scripts must be idempotent, .env-driven, with preflight + post-checks + teardown per phase. --- @@ -17,7 +19,7 @@ Migrating 3 GitHub repos to self-hosted Gitea (Unraid primary, Fedora backup mir | Template rendering | `.tpl` files + `envsubst` | Keeps config templates separate from script logic | | Error handling | `set -euo pipefail` + trap cleanup | Fail fast, no silent errors | | API interaction | Shared `gitea_api()` curl wrapper with JSON/jq | Consistent auth, error checking, response parsing | -| HTTPS proxy | Nginx config template + Certbot (Docker) | Plain Nginx already on Unraid as Docker container — just add a server block + SSL cert | +| HTTPS proxy | Caddy reverse proxy with Cloudflare DNS-01 or existing certs | Automatic TLS with zero-touch renewal; each host gets a dedicated macvlan IP | --- @@ -43,7 +45,9 @@ gitea-migration/ │ ├── app.ini.tpl │ ├── runner-config.yaml.tpl │ ├── com.gitea.runner.plist.tpl -│ ├── nginx-gitea.conf.tpl +│ ├── Caddyfile.tpl +│ ├── docker-compose-caddy.yml.tpl +│ ├── com.gitea.runner.newsyslog.conf.tpl │ └── workflows/ │ └── security-scan.yml.tpl ├── backup/ @@ -418,26 +422,26 @@ gitea-migration/ --- -### 2.6 — `templates/nginx-gitea.conf.tpl` +### 2.6 — `templates/Caddyfile.tpl` + `docker-compose-caddy.yml.tpl` -**Depends on**: .env vars -**Produces**: Nginx server block for reverse-proxying Gitea +**Depends on**: .env vars (`GITEA_DOMAIN`, `TLS_BLOCK`, `GITEA_CONTAINER_IP`, `CADDY_DATA_PATH`, `CADDY_CONTAINER_IP`) +**Produces**: Caddy reverse proxy config + Docker Compose for Caddy container -**Must include**: -- `server_name $GITEA_DOMAIN` -- `listen 443 ssl` -- `ssl_certificate` / `ssl_certificate_key` paths (Certbot standard: `/etc/letsencrypt/live/$GITEA_DOMAIN/`) -- `proxy_pass http://$UNRAID_IP:$UNRAID_GITEA_PORT` -- Proxy headers: `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, `Host` -- WebSocket support: `proxy_set_header Upgrade`, `Connection "upgrade"` -- HTTP→HTTPS redirect block (listen 80, return 301) -- `client_max_body_size 512m` (for large git pushes) +**Caddyfile must include**: +- `${GITEA_DOMAIN}` as the site address +- `${TLS_BLOCK}` placeholder (script sets to `tls { dns cloudflare {env.CF_API_TOKEN} }` or `tls /path/cert /path/key`) +- `reverse_proxy ${GITEA_CONTAINER_IP}:3000` + +**Docker Compose must include**: +- `slothcroissant/caddy-cloudflaredns:latest` image +- Volume mounts for Caddyfile, data, and config +- macvlan network with static IP (`CADDY_CONTAINER_IP`) +- Conditional `CF_API_TOKEN` env var and cert volume mounts based on TLS mode **Done when**: -- [ ] `nginx -t` passes against rendered config (can test by running nginx in Docker locally) -- [ ] WebSocket headers present (needed for Gitea's live features) -- [ ] HTTP→HTTPS redirect included -- [ ] SSL cert paths match Certbot convention +- [ ] Caddy starts and obtains TLS certificate +- [ ] HTTPS proxy works to Gitea +- [ ] HTTP redirects to HTTPS --- @@ -526,12 +530,12 @@ gitea-migration/ | 34 | `MIGRATE_MILESTONES` | true/false | false | | 35 | `MIGRATE_WIKI` | true/false | false | | 36 | `GITHUB_MIRROR_INTERVAL` | Non-empty | 8h | -| 37 | `NGINX_CONTAINER_NAME` | Non-empty | — | -| 39 | `NGINX_CONF_PATH` | Absolute path | — | -| 40 | `SSL_MODE` | `letsencrypt` or `existing` | letsencrypt | -| 41 | `SSL_EMAIL` | Email *(only if SSL_MODE=letsencrypt)* | — | -| 42 | `SSL_CERT_PATH` | Absolute path *(only if SSL_MODE=existing)* | — | -| 43 | `SSL_KEY_PATH` | Absolute path *(only if SSL_MODE=existing)* | — | +| 37 | `TLS_MODE` | `cloudflare` or `existing` | cloudflare | +| 38 | `CADDY_DOMAIN` | Non-empty | — | +| 39 | `CADDY_DATA_PATH` | Absolute path | — | +| 40 | `CLOUDFLARE_API_TOKEN` | Non-empty *(only if TLS_MODE=cloudflare)* | — | +| 41 | `SSL_CERT_PATH` | Absolute path *(only if TLS_MODE=existing)* | — | +| 42 | `SSL_KEY_PATH` | Absolute path *(only if TLS_MODE=existing)* | — | | 44 | `PROTECTED_BRANCH` | Non-empty | main | | 45 | `REQUIRE_PR_REVIEW` | true/false | false | | 46 | `REQUIRED_APPROVALS` | Integer | 1 | @@ -541,9 +545,10 @@ gitea-migration/ | 50 | `SECURITY_FAIL_ON_ERROR` | true/false | true | **Done when**: -- [ ] Each prompt shows progress: `[N/50]` with section header when entering a new section -- [ ] Running with no existing `.env` walks through all 50 prompts and produces a valid `.env` -- [ ] SSL prompts are conditional: if `SSL_MODE=letsencrypt`, prompt for `SSL_EMAIL` only; if `SSL_MODE=existing`, prompt for `SSL_CERT_PATH` and `SSL_KEY_PATH` only +- [ ] Each prompt shows progress: `[N/~62]` with section header when entering a new section +- [ ] Running with no existing `.env` walks through all prompts and produces a valid `.env` +- [ ] TLS prompts are conditional: if `TLS_MODE=cloudflare`, prompt for `CLOUDFLARE_API_TOKEN` only; if `TLS_MODE=existing`, prompt for `SSL_CERT_PATH` and `SSL_KEY_PATH` only +- [ ] DB prompts are conditional: if `GITEA_DB_TYPE` is not `sqlite3`, prompt for host/port/name/user/password - [ ] Running with an existing `.env` shows current values and only overwrites what user changes - [ ] Invalid input (bad IP, path not starting with `/`, password too short) re-prompts with error message - [ ] Auto-populated vars are skipped entirely (no prompt, no overwrite) @@ -663,25 +668,23 @@ gitea-migration/ | Repos | `GITHUB_TOKEN` | | Repos | `REPO_NAMES` | | Mirror | `GITHUB_TOKEN` | -| Nginx | `NGINX_CONTAINER_NAME` | -| Nginx | `NGINX_CONF_PATH` | -| Nginx | `SSL_EMAIL` | +| TLS/Caddy | `TLS_MODE` | +| TLS/Caddy | `CADDY_DOMAIN` | +| TLS/Caddy | `CADDY_DATA_PATH` | **Not checked** (have defaults or auto-populated): -`UNRAID_SSH_PORT`, `UNRAID_GITEA_PORT`, `UNRAID_GITEA_SSH_PORT`, `FEDORA_SSH_PORT`, `FEDORA_GITEA_PORT`, `FEDORA_GITEA_SSH_PORT`, `GITEA_DB_TYPE`, `GITEA_VERSION`, `ACT_RUNNER_VERSION`, `GITEA_BACKUP_MIRROR_INTERVAL`, `BACKUP_RETENTION_COUNT`, `MIGRATE_*`, `GITHUB_MIRROR_INTERVAL`, `PROTECTED_BRANCH`, `REQUIRE_PR_REVIEW`, `REQUIRED_APPROVALS`, `SEMGREP_VERSION`, `TRIVY_VERSION`, `GITLEAKS_VERSION`, `SECURITY_FAIL_ON_ERROR`, `GITEA_ADMIN_TOKEN`, `GITEA_BACKUP_ADMIN_TOKEN`, `GITEA_RUNNER_REGISTRATION_TOKEN` +`UNRAID_SSH_PORT`, `FEDORA_SSH_PORT`, `GITEA_DB_TYPE`, `GITEA_VERSION`, `ACT_RUNNER_VERSION`, `GITEA_BACKUP_MIRROR_INTERVAL`, `BACKUP_RETENTION_COUNT`, `MIGRATE_*`, `GITHUB_MIRROR_INTERVAL`, `PROTECTED_BRANCH`, `REQUIRE_PR_REVIEW`, `REQUIRED_APPROVALS`, `SEMGREP_VERSION`, `TRIVY_VERSION`, `GITLEAKS_VERSION`, `SECURITY_FAIL_ON_ERROR`, `GITEA_ADMIN_TOKEN`, `GITEA_BACKUP_ADMIN_TOKEN`, `GITEA_RUNNER_REGISTRATION_TOKEN` | 4 | SSH to Unraid | `ssh_check UNRAID` returns 0 | "Cannot SSH to Unraid at $UNRAID_IP. Run setup/unraid.sh or check SSH config." | | 5 | SSH to Fedora | `ssh_check FEDORA` returns 0 | Same pattern | | 6 | Docker on Unraid | `ssh_exec UNRAID "docker --version"` exits 0 | "Docker not found on Unraid. Run setup/unraid.sh." | | 7 | Docker on Fedora | Same | Same | | 8 | docker-compose on Unraid | `ssh_exec UNRAID "docker compose version"` or `docker-compose --version` | "docker-compose not found on Unraid. Run setup/unraid.sh." | | 9 | docker-compose on Fedora | Same | Same | -| 10 | Port 3000 free on Unraid | `ssh_exec UNRAID "! ss -tlnp \| grep -q ':$UNRAID_GITEA_PORT '"` | "Port $UNRAID_GITEA_PORT already in use on Unraid." | -| 11 | Port 3000 free on Fedora | Same | Same | +| 10 | Container IPs available | Ping-check `UNRAID_GITEA_IP`, `UNRAID_CADDY_IP`, `FEDORA_GITEA_IP`, `FEDORA_CADDY_IP` — warn if responding | "IP $ip is already responding to ping (may be in use)." | | 12 | DNS resolves | `dig +short $GITEA_DOMAIN` returns `$UNRAID_IP` | "$GITEA_DOMAIN does not resolve to $UNRAID_IP." | | 13 | GitHub token valid | `github_api GET /user` returns 200 | "GitHub token invalid. Check GITHUB_TOKEN in .env." | -| 14 | GitHub repos exist | For each REPO_N_NAME: `github_api GET /repos/$GITHUB_USERNAME/$REPO_N_NAME` returns 200 | "GitHub repo $REPO_N_NAME not found under $GITHUB_USERNAME." | -| 15 | Nginx running on Unraid | `ssh_exec UNRAID "docker ps --filter name=$NGINX_CONTAINER_NAME --format '{{.Status}}'"` contains "Up" | "Nginx container '$NGINX_CONTAINER_NAME' not running on Unraid." | -| 16 | Nginx conf dir writable | `ssh_exec UNRAID "test -w $NGINX_CONF_PATH"` | "Nginx config path $NGINX_CONF_PATH not writable." | +| 14 | GitHub repos exist | For each repo in `REPO_NAMES`: `github_api GET /repos/$GITHUB_USERNAME/$repo` returns 200 | "GitHub repo $repo not found under $GITHUB_USERNAME." | +| 15 | Caddy data path writable | `ssh_exec UNRAID "test -w $CADDY_DATA_PATH"` or parent dir writable | "Caddy data path $CADDY_DATA_PATH not writable on Unraid." | **Exit behavior**: Runs ALL checks (doesn't stop at first failure). Prints summary at end. Exits 0 if all pass, 1 if any fail. @@ -1038,70 +1041,50 @@ Teardown: `gitea_api DELETE /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRAN ### 12.1 — `phase8_cutover.sh` -**Depends on**: Nginx running on Unraid, all prior phases -**Produces**: HTTPS access to Gitea, GitHub repos archived -**`require_vars`**: `UNRAID_IP`, `UNRAID_SSH_USER`, `UNRAID_SSH_PORT`, `UNRAID_GITEA_PORT`, `GITEA_INTERNAL_URL`, `GITEA_DOMAIN`, `GITEA_ADMIN_TOKEN` *(auto)*, `GITEA_ORG_NAME`, `NGINX_CONTAINER_NAME`, `NGINX_CONF_PATH`, `SSL_EMAIL`, `GITHUB_USERNAME`, `GITHUB_TOKEN`, `REPO_NAMES` +**Depends on**: macvlan network created (Phase 1), all prior phases +**Produces**: HTTPS access to Gitea via Caddy, GitHub repos marked as mirrors +**`require_vars`**: `UNRAID_IP`, `UNRAID_SSH_USER`, `UNRAID_GITEA_IP`, `UNRAID_CADDY_IP`, `GITEA_INTERNAL_URL`, `GITEA_DOMAIN`, `GITEA_ADMIN_TOKEN` *(auto)*, `GITEA_ORG_NAME`, `TLS_MODE`, `CADDY_DOMAIN`, `CADDY_DATA_PATH`, `GITHUB_USERNAME`, `GITHUB_TOKEN`, `REPO_NAMES`. Conditional: `CLOUDFLARE_API_TOKEN` (if `TLS_MODE=cloudflare`), `SSL_CERT_PATH` + `SSL_KEY_PATH` (if `TLS_MODE=existing`). **Steps with idempotency**: | # | Action | Detail | Idempotency check (skip if true) | |---|--------|--------|----------------------------------| -| 1 | Deploy HTTP-only Nginx config | Render `nginx-gitea.conf.tpl` in **HTTP-only mode** (no SSL directives). This serves: (a) reverse proxy to Gitea on port 80, (b) `/.well-known/acme-challenge/` location for Certbot webroot validation. SCP to `$NGINX_CONF_PATH/gitea.conf`. | `ssh_exec UNRAID "test -f $NGINX_CONF_PATH/gitea.conf"` | -| 2 | Test Nginx config | `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -t"` — if fails, remove config and exit 1 | — (always run) | -| 3 | Reload Nginx (HTTP) | `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"` | — | -| 4 | Verify HTTP proxy works | `curl -sf http://$GITEA_DOMAIN/api/v1/version` returns 200 | — | -| 5 | Obtain or verify SSL cert | **If `SSL_MODE=letsencrypt`**: `ssh_exec UNRAID "docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /var/www/html:/var/www/html certbot/certbot certonly --webroot -w /var/www/html -d $GITEA_DOMAIN --email $SSL_EMAIL --agree-tos --non-interactive"`. **If `SSL_MODE=existing`**: verify cert files exist at `$SSL_CERT_PATH` and `$SSL_KEY_PATH` on Unraid: `ssh_exec UNRAID "test -f $SSL_CERT_PATH && test -f $SSL_KEY_PATH"` — fail if missing. | **letsencrypt**: `ssh_exec UNRAID "test -f /etc/letsencrypt/live/$GITEA_DOMAIN/fullchain.pem"`. **existing**: cert files already verified. | -| 6 | Deploy HTTPS Nginx config | Re-render `nginx-gitea.conf.tpl` in **HTTPS mode** (adds `listen 443 ssl`, cert paths, HTTP→HTTPS redirect). SCP to `$NGINX_CONF_PATH/gitea.conf` (overwrites HTTP-only version). | Cert exists from step 5 | -| 7 | Test Nginx config (HTTPS) | `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -t"` — if fails, revert to HTTP-only config and exit 1 | — (always run) | -| 8 | Reload Nginx (HTTPS) | `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"` | — | -| 9 | Verify HTTPS works | `curl -sf https://$GITEA_DOMAIN/api/v1/version` returns 200. Also: `curl -sI https://$GITEA_DOMAIN` to confirm no redirect loops. | — | -| 10 | Set up cert auto-renewal cron | **Only if `SSL_MODE=letsencrypt`**: `ssh_exec UNRAID "echo '0 3 * * * docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /var/www/html:/var/www/html certbot/certbot renew --quiet && docker exec $NGINX_CONTAINER_NAME nginx -s reload' \| crontab -"` — runs daily at 3 AM. **If `SSL_MODE=existing`**: skip (user manages their own cert renewal). | `ssh_exec UNRAID "crontab -l 2>/dev/null \| grep -q certbot"` | -| 11 | Archive GitHub repos | For each repo: (a) Save original description: `github_api GET /repos/$GITHUB_USERNAME/$REPO` → store `description` field, (b) `github_api PATCH /repos/$GITHUB_USERNAME/$REPO {"archived": true, "description": "[MOVED] Now at https://$GITEA_DOMAIN/$GITEA_ORG_NAME/$REPO — was: $ORIGINAL_DESCRIPTION"}` | `github_api GET /repos/$GITHUB_USERNAME/$REPO` has `"archived": true` | +| 1 | Create Caddy dirs | `ssh_exec UNRAID "mkdir -p $CADDY_DATA_PATH/{data,config}"` | `test -d $CADDY_DATA_PATH` | +| 2 | Deploy Caddyfile | Render `Caddyfile.tpl` with TLS_BLOCK (cloudflare DNS-01 or existing cert paths), SCP to `$CADDY_DATA_PATH/Caddyfile` | `test -f $CADDY_DATA_PATH/Caddyfile` | +| 3 | Deploy Caddy docker-compose | Render `docker-compose-caddy.yml.tpl`, SCP to `$CADDY_DATA_PATH/docker-compose.yml` | `test -f $CADDY_DATA_PATH/docker-compose.yml` | +| 4 | Start Caddy | `docker compose up -d` in `$CADDY_DATA_PATH` | Caddy container already running | +| 5 | Wait for HTTPS | Poll `https://$GITEA_DOMAIN/api/v1/version` with retries until cert is obtained | — | +| 6 | Mark GitHub repos as mirrors | Save pre-cutover state to `.manifests/phase8_github_repo_state.json`, update description to `[MIRROR]`, disable wiki/projects/Pages | GitHub repo description starts with `[MIRROR]` | -**Nginx template must support two render passes**: -The `nginx-gitea.conf.tpl` template is rendered with `$SSL_ENABLED=true/false` (set by the script, not .env): -- **HTTP-only** (`SSL_ENABLED=false`): `listen 80`, proxy_pass, ACME challenge location (if `SSL_MODE=letsencrypt`), no SSL directives -- **HTTPS** (`SSL_ENABLED=true`): `listen 443 ssl`, cert paths, `listen 80` with 301 redirect, proxy_pass, WebSocket headers - -**Cert paths in the template depend on `SSL_MODE`**: -- `letsencrypt`: `ssl_certificate /etc/letsencrypt/live/$GITEA_DOMAIN/fullchain.pem`, `ssl_certificate_key /etc/letsencrypt/live/$GITEA_DOMAIN/privkey.pem` -- `existing`: `ssl_certificate $SSL_CERT_PATH`, `ssl_certificate_key $SSL_KEY_PATH` - -**Certbot volume mounts**: -- `/etc/letsencrypt` on Unraid host → mounted into both Certbot container and Nginx container -- `/var/www/html` on Unraid host → Nginx serves this for ACME challenges, Certbot writes challenge files here -- Verify these mount paths exist on the Nginx container: `ssh_exec UNRAID "docker inspect $NGINX_CONTAINER_NAME --format '{{json .Mounts}}'"` — if `/etc/letsencrypt` or webroot is not mounted, the script must fail with instructions to add the volume mounts to the Nginx container config. +**TLS mode handling**: +- `cloudflare`: Caddyfile uses `tls { dns cloudflare {env.CF_API_TOKEN} }`, docker-compose passes `CF_API_TOKEN` env var +- `existing`: Caddyfile uses `tls /path/to/cert /path/to/key`, docker-compose mounts cert/key as volumes **Done when**: -- [ ] `https://$GITEA_DOMAIN` returns valid HTTPS response (no cert errors) -- [ ] `curl https://$GITEA_DOMAIN/api/v1/version` returns Gitea version JSON -- [ ] Certificate is from Let's Encrypt: `openssl s_client -connect $GITEA_DOMAIN:443 /dev/null | openssl x509 -noout -issuer` contains "Let's Encrypt" -- [ ] HTTP requests redirect to HTTPS: `curl -sI http://$GITEA_DOMAIN` returns 301 with `Location: https://...` -- [ ] Cert auto-renewal cron is installed -- [ ] All GitHub repos show as archived with original description preserved in the new description -- [ ] Nginx config test passes before every reload (never reload a broken config) -- [ ] Running again skips cert generation, skips already-archived repos -- [ ] If Nginx container doesn't have required volume mounts, script fails with clear instructions +- [ ] `https://$GITEA_DOMAIN` returns valid HTTPS response +- [ ] HTTP requests redirect to HTTPS (301) +- [ ] SSL certificate is valid (openssl check) +- [ ] All repos accessible via HTTPS API +- [ ] GitHub repos marked with `[MIRROR]` description prefix --- ### 12.2 — `phase8_post_check.sh` -- [ ] HTTPS works with valid cert: `curl -sf https://$GITEA_DOMAIN/` returns 200 -- [ ] Certificate is from Let's Encrypt (not self-signed): check with `openssl s_client` -- [ ] All repos accessible: `curl -sf https://$GITEA_DOMAIN/$ORG/$REPO` returns 200 -- [ ] GitHub repos are archived: `github_api GET /repos/$USER/$REPO` has `"archived": true` +- [ ] HTTPS works with valid cert: `curl -sf https://$GITEA_DOMAIN/api/v1/version` returns 200 +- [ ] HTTP redirects to HTTPS: `curl -sI http://$GITEA_DOMAIN/` returns 301 +- [ ] Certificate is valid: `openssl s_client` returns non-empty issuer +- [ ] All repos accessible: API call to each repo returns 200 +- [ ] GitHub repos marked as mirrors: description starts with `[MIRROR]` --- ### 12.3 — `phase8_teardown.sh` -1. Remove Nginx config: `ssh_exec UNRAID "rm -f $NGINX_CONF_PATH/gitea.conf"` -2. Reload Nginx: `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"` -3. Remove cert renewal cron: `ssh_exec UNRAID "crontab -l 2>/dev/null | grep -v certbot | crontab -"` -4. Prompt: "Remove SSL certificates for $GITEA_DOMAIN? [y/N]" - - If yes: `ssh_exec UNRAID "rm -rf /etc/letsencrypt/live/$GITEA_DOMAIN /etc/letsencrypt/archive/$GITEA_DOMAIN /etc/letsencrypt/renewal/$GITEA_DOMAIN.conf"` -5. Un-archive GitHub repos: for each repo, `github_api PATCH /repos/$GITHUB_USERNAME/$REPO {"archived": false}`. Restore original description if it was saved in the archive description (parse after "was: "). +1. Stop + remove Caddy container: `docker compose down` in `$CADDY_DATA_PATH` +2. Remove Caddy config files: `rm -f $CADDY_DATA_PATH/docker-compose.yml $CADDY_DATA_PATH/Caddyfile` +3. Optionally remove Caddy TLS data: `rm -rf $CADDY_DATA_PATH/data $CADDY_DATA_PATH/config` +4. Restore GitHub repo settings from saved Phase 8 state snapshot (description, homepage, wiki, projects, Pages). Falls back to parsing `[MIRROR] ... — was: ORIGINAL` if snapshot missing. ---