R510 -> R730XD/Proxmox migration helpers (consistent DB dumps, B2/Kopia snapshot refresh, cutover final backup) plus the previously-untracked gitea-portainer-registry cutover plan under docs/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
48 KiB
Gitea Portainer Registry Cutover Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Move homelab deployment source control from GitHub to local Gitea, keep GitHub as a temporary rollback mirror, move Gitea and the Docker registry into the management plane, publish famapp images to registry.ginnoir.com, and recreate Portainer stacks with stack.env as the only app-stack environment source.
Architecture: The management plane becomes the dependency root for Portainer, Vault, Gitea, Gitea runner, Docker registry, registry UI, and Watchtower. Portainer app stacks are recreated to poll https://gitea.ginnoir.com/ginnoir/homelabstack.git, with GitHub retained only as a temporary push mirror. Famapp release automation moves to Gitea Actions and publishes images to the self-hosted Docker registry.
Tech Stack: Docker Compose, Portainer CE Git stacks, Gitea, Gitea Actions act_runner, Docker registry v2, Caddy, PowerShell, SSH to ginnoir@valhalla, famapp Next.js Docker image.
File Structure
Homelab repo files:
- Modify
portainer-compose.yml- Add Gitea,
postgres_gitea,gitea_runner,registry, andregistry_uito the raw management compose file. - Add registry auth bind mounts and environment.
- Keep
portainer,vault, andwatchtowerin place. - Remove
github-runneronly after Gitea Actions and Caddy reloads are proven.
- Add Gitea,
- Modify
.env- Add management-plane registry credential variables and Gitea runner token variables.
- Retain GitHub token variables during the staged mirror period.
- Modify
stacks/dev/docker-compose.yml- Remove Gitea,
postgres_gitea,gitea_runner,registry, andregistry_ui. - Convert remaining services away from
${VAR}interpolation by storing container-native variables directly instacks/dev/stack.env.
- Remove Gitea,
- Modify
stacks/dev/stack.env- Preserve existing secret values while adding the names that containers actually read:
APP_KEY,DB_PASS,MYSQL_ROOT_PASSWORD,MYSQL_PASSWORD,SECRET_KEY,DATABASE_URL,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,MINIO_USER,MINIO_PASSWORD,POSTGRES_PASSWORD,MINIO_ROOT_USER, andMINIO_ROOT_PASSWORD. - Remove Gitea-only variables after Gitea is moved to
.env.
- Preserve existing secret values while adding the names that containers actually read:
- Modify
stacks/resume/docker-compose.yml- Remove MinIO OIDC
${VAR}interpolation by makingMINIO_IDENTITY_OPENID_CLIENT_IDandMINIO_IDENTITY_OPENID_CLIENT_SECRETdirectstack.envkeys.
- Remove MinIO OIDC
- Modify
stacks/resume/stack.env- Add
MINIO_IDENTITY_OPENID_CLIENT_IDwith the currentMINIO_OIDC_CLIENT_IDvalue. - Add
MINIO_IDENTITY_OPENID_CLIENT_SECRETwith the currentMINIO_OIDC_CLIENT_SECRETvalue.
- Add
- Modify
stacks/famapp/docker-compose.yml- Change
famappimage fromghcr.io/ginnoir/famapp:latesttoregistry.ginnoir.com/ginnoir/famapp:latest. - Keep
pull_policy: alwaysduring the staged cutover.
- Change
- Modify
stacks/proxy/docker-compose.yml- Update comments from GitHub runner reloads to Gitea Actions reloads after the workflow is proven.
- Modify
Caddyfile- Keep
registry.ginnoir.cominternal-only. - Keep
gitea.ginnoir.comGit/API bypasses. - Adjust upstreams only if service names or networks change.
- Keep
- Modify
CLAUDE.mdandAGENTS.md- Replace GitHub-primary deploy language with Gitea-primary, GitHub-temporary-mirror language.
- Record that Portainer app stacks must have no Portainer UI env vars.
- Create
scripts/check-portainer-stack-env.ps1- Fail if any non-comment line in
stacks/*/docker-compose.ymlcontains${. - Fail if a service that requires
env_file: stack.envlacks it. - Allow explicit, reviewed exemptions in
scripts/portainer-stack-env-exemptions.json.
- Fail if any non-comment line in
- Create
scripts/portainer-stack-env-exemptions.json- List services that intentionally need no
stack.env.
- List services that intentionally need no
- Create
scripts/new-registry-auth.ps1- Generate registry push/pull credentials and a bcrypt
htpasswdfile without printing passwords to the terminal after creation.
- Generate registry push/pull credentials and a bcrypt
Famapp repo files:
- Modify
.github/workflows/ci.ymlonly if GitHub CI remains as temporary mirror validation. - Create
.gitea/workflows/ci.yml- Mirror current CI checks in Gitea Actions.
- Create
.gitea/workflows/release.yml- Build and push
registry.ginnoir.com/ginnoir/famapp.
- Build and push
- Modify
.release-it.json- Disable GitHub release creation or move release publication to Gitea-compatible tagging only.
- Modify
README.md,docs/RUNBOOK.md,docs/ENV.md,deploy/README.md, anddeploy/compose.example.yaml- Replace GHCR image references with
registry.ginnoir.com/ginnoir/famapp. - Replace GitHub-primary clone/release text with Gitea-primary text.
- Replace GHCR image references with
- Verify
.git/config- Confirm there are no
manta-cyberorRTSOKremotes. - Leave GitHub
originonly as a temporary mirror until final cleanup.
- Confirm there are no
Live system targets:
ginnoir@valhalla~/valhalla-lab/portainer-compose.yml~/valhalla-lab/.env/config/registry/auth/htpasswd/config/portainer/storage1/labdata/registry/config/gitea/storage1/labdata/gitea/repositories/storage1/labdata/gitea/lfs
Task 1: Baseline Live State and Protect Against Cross-Repo Drift
Files:
-
Read:
portainer-compose.yml -
Read:
stacks/dev/docker-compose.yml -
Read:
stacks/famapp/docker-compose.yml -
Read:
C:\Users\MattC\Documents\famapp\.git\config -
Step 1: Confirm homelab repo state
Run:
git -C C:\Users\MattC\Documents\homelabstack status --short --branch
git -C C:\Users\MattC\Documents\homelabstack remote -v
Expected:
## main...origin/main
gitea https://gitea.ginnoir.com/ginnoir/homelabstack.git (fetch)
gitea https://gitea.ginnoir.com/ginnoir/homelabstack.git (push)
origin https://github.com/ginnoir/homelabstack.git (fetch)
origin https://github.com/ginnoir/homelabstack.git (push)
origin https://gitea.ginnoir.com/ginnoir/homelabstack.git (push)
- Step 2: Confirm famapp has no work-repo remote
Run:
git -C C:\Users\MattC\Documents\famapp remote -v
Select-String -Path C:\Users\MattC\Documents\famapp\.git\config -Pattern 'manta-cyber|RTSOK'
Expected:
gitea https://gitea.ginnoir.com/ginnoir/famapp.git (fetch)
gitea https://gitea.ginnoir.com/ginnoir/famapp.git (push)
origin https://github.com/ginnoir/famapp.git (fetch)
origin https://github.com/ginnoir/famapp.git (push)
origin https://gitea.ginnoir.com/ginnoir/famapp.git (push)
Select-String should return no matches.
- Step 3: Confirm live dependency containers
Run:
ssh -o BatchMode=yes -o ConnectTimeout=8 ginnoir@valhalla "docker ps --format '{{.Names}} {{.Image}} {{.Status}}' | egrep '^(portainer|gitea|postgres_gitea|gitea_runner|registry|registry_ui|github_runner|caddy|famapp) '"
Expected:
portainer ... Up ...
gitea ... Up ...
postgres_gitea ... Up ...
gitea_runner ... Up ...
registry ... Up ...
registry_ui ... Up ...
github_runner ... Up ...
caddy ... Up ...
famapp ... Up ...
- Step 4: Confirm registry and Gitea endpoints
Run:
ssh -o BatchMode=yes -o ConnectTimeout=8 ginnoir@valhalla "curl -skI https://registry.ginnoir.com/v2/ | head -5; curl -skI https://gitea.ginnoir.com/ | head -5"
Expected before registry auth:
HTTP/2 200
...
HTTP/2 200
Expected after registry auth in later tasks:
HTTP/2 401
www-authenticate: Basic realm="Valhalla Registry"
...
HTTP/2 200
- Step 5: Commit baseline-free state
No commit is needed in this task. If any command shows unexpected dirty files, stop and identify whether they are user changes before continuing.
Task 2: Add Stack Env Guardrails
Files:
-
Create:
scripts/check-portainer-stack-env.ps1 -
Create:
scripts/portainer-stack-env-exemptions.json -
Step 1: Create the exemption file
Create scripts/portainer-stack-env-exemptions.json:
{
"authentik": ["authentik-redis"],
"dev": ["dbx", "redis_plane"],
"foundry": ["5etools"],
"monitoring": ["uptime-kuma"],
"owncloud": ["redis"]
}
Do not include registry or registry_ui here because they will move to the management plane.
- Step 2: Create the guard script
Create scripts/check-portainer-stack-env.ps1:
[CmdletBinding()]
param(
[string]$StacksRoot = (Join-Path $PSScriptRoot "..\stacks"),
[string]$ExemptionsPath = (Join-Path $PSScriptRoot "portainer-stack-env-exemptions.json")
)
$ErrorActionPreference = "Stop"
$failures = New-Object System.Collections.Generic.List[string]
$exemptions = @{}
if (Test-Path $ExemptionsPath) {
$raw = Get-Content -Raw -LiteralPath $ExemptionsPath | ConvertFrom-Json
foreach ($property in $raw.PSObject.Properties) {
$exemptions[$property.Name] = @($property.Value)
}
}
function Get-ServiceBlocks {
param([string[]]$Lines)
$inServices = $false
$currentName = $null
$currentLines = New-Object System.Collections.Generic.List[string]
$blocks = New-Object System.Collections.Generic.List[object]
foreach ($line in $Lines) {
if ($line -match '^services:\s*$') {
$inServices = $true
continue
}
if (-not $inServices) {
continue
}
if ($line -match '^[A-Za-z0-9_-]+:\s*$') {
break
}
if ($line -match '^ ([A-Za-z0-9_-]+):\s*$') {
if ($null -ne $currentName) {
$blocks.Add([pscustomobject]@{
Name = $currentName
Lines = @($currentLines)
})
}
$currentName = $Matches[1]
$currentLines = New-Object System.Collections.Generic.List[string]
continue
}
if ($null -ne $currentName) {
$currentLines.Add($line)
}
}
if ($null -ne $currentName) {
$blocks.Add([pscustomobject]@{
Name = $currentName
Lines = @($currentLines)
})
}
return $blocks
}
Get-ChildItem -LiteralPath $StacksRoot -Directory | Sort-Object Name | ForEach-Object {
$stackName = $_.Name
$composePath = Join-Path $_.FullName "docker-compose.yml"
$stackEnvPath = Join-Path $_.FullName "stack.env"
if (-not (Test-Path $composePath)) {
return
}
if (-not (Test-Path $stackEnvPath)) {
$failures.Add("${stackName}: missing stack.env")
}
$lines = Get-Content -LiteralPath $composePath
$interpolations = Select-String -LiteralPath $composePath -Pattern '\$\{' |
Where-Object { $_.Line -notmatch '^\s*#' }
foreach ($match in $interpolations) {
$failures.Add("${stackName}: compose interpolation at $($match.Path):$($match.LineNumber): $($match.Line.Trim())")
}
$allowedMissing = @()
if ($exemptions.ContainsKey($stackName)) {
$allowedMissing = @($exemptions[$stackName])
}
foreach ($service in Get-ServiceBlocks -Lines $lines) {
$hasEnvFile = $service.Lines | Where-Object { $_ -match '^\s+env_file:\s*$' -or $_ -match '^\s+- stack\.env\s*$' }
if (-not $hasEnvFile -and $allowedMissing -notcontains $service.Name) {
$failures.Add("${stackName}/${service.Name}: missing env_file: stack.env")
}
}
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Error $_ }
exit 1
}
Write-Host "Portainer stack env checks passed."
- Step 3: Run the guard and prove current failure
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-portainer-stack-env.ps1
Expected before fixes:
Write-Error: dev: compose interpolation at ...
Write-Error: resume: compose interpolation at ...
- Step 4: Commit guardrails
Run:
git add scripts/check-portainer-stack-env.ps1 scripts/portainer-stack-env-exemptions.json
git commit -m "test: add portainer stack env guard"
Expected: commit exits 0 and prints a one-line summary for test: add portainer stack env guard.
Task 3: Prepare Registry Auth Material
Files:
-
Create:
scripts/new-registry-auth.ps1 -
Modify:
.env -
Step 1: Create registry credential generator
Create scripts/new-registry-auth.ps1:
[CmdletBinding()]
param(
[string]$OutputDirectory = (Join-Path $env:TEMP "valhalla-registry-auth"),
[string]$PushUser = "registry_push",
[string]$PullUser = "registry_pull"
)
$ErrorActionPreference = "Stop"
function New-Secret {
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
return [Convert]::ToBase64String($bytes).TrimEnd("=")
}
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$pushPassword = New-Secret
$pullPassword = New-Secret
$htpasswdPath = Join-Path $OutputDirectory "htpasswd"
$envPath = Join-Path $OutputDirectory "registry.env"
$pushLine = docker run --rm --entrypoint htpasswd httpd:2 -Bbn $PushUser $pushPassword
$pullLine = docker run --rm --entrypoint htpasswd httpd:2 -Bbn $PullUser $pullPassword
Set-Content -LiteralPath $htpasswdPath -NoNewline -Value ($pushLine + "`n" + $pullLine + "`n")
Set-Content -LiteralPath $envPath -NoNewline -Value @"
REGISTRY_PUSH_USERNAME=$PushUser
REGISTRY_PUSH_PASSWORD=$pushPassword
REGISTRY_PULL_USERNAME=$PullUser
REGISTRY_PULL_PASSWORD=$pullPassword
"@
Write-Host "Created:"
Write-Host " $htpasswdPath"
Write-Host " $envPath"
Write-Host "Append registry.env values to .env, then copy htpasswd to valhalla:/config/registry/auth/htpasswd."
- Step 2: Generate credentials
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\new-registry-auth.ps1
Expected: the script prints concrete paths under $env:TEMP\valhalla-registry-auth for htpasswd and registry.env.
- Step 3: Add generated registry credentials to
.env
Open the generated registry.env, copy these four generated key/value lines into .env under a new section:
Add this section to .env, using the exact generated values from $env:TEMP\valhalla-registry-auth\registry.env:
# =============================================================
# DOCKER REGISTRY
# =============================================================
REGISTRY_PUSH_USERNAME=registry_push
REGISTRY_PUSH_PASSWORD=value generated by scripts/new-registry-auth.ps1
REGISTRY_PULL_USERNAME=registry_pull
REGISTRY_PULL_PASSWORD=value generated by scripts/new-registry-auth.ps1
Replace the two value generated by... strings with the actual generated password values before saving .env.
- Step 4: Commit generator and
.envcredential additions
Run:
git add scripts/new-registry-auth.ps1 .env
git commit -m "chore: add registry auth credentials"
Expected: commit exits 0 and prints a one-line summary for chore: add registry auth credentials.
Task 4: Move Gitea and Registry to the Management Plane
Files:
-
Modify:
portainer-compose.yml -
Modify:
.env -
Modify:
stacks/dev/docker-compose.yml -
Modify:
stacks/dev/stack.env -
Step 1: Add management services to
portainer-compose.yml
In portainer-compose.yml, add these services after vault and before github-runner:
postgres_gitea:
container_name: postgres_gitea
image: postgres:16-alpine
restart: unless-stopped
labels:
- "com.centurylabs.watchtower.enable=false"
networks:
- gitea_internal
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
POSTGRES_DB: gitea
volumes:
- /config/gitea/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
interval: 10s
timeout: 5s
retries: 5
gitea:
container_name: gitea
image: gitea/gitea:latest
restart: unless-stopped
networks:
- gitea_internal
- edge
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=postgres_gitea:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${GITEA_DB_PASSWORD}
- GITEA__server__DOMAIN=gitea.ginnoir.com
- GITEA__server__ROOT_URL=https://gitea.ginnoir.com
- GITEA__server__SSH_DOMAIN=gitea.ginnoir.com
- GITEA__server__SSH_PORT=2222
- GITEA__repository__ROOT=/repos
- GITEA__lfs__PATH=/repos-lfs
volumes:
- /config/gitea:/data
- /storage1/labdata/gitea/repositories:/repos
- /storage1/labdata/gitea/lfs:/repos-lfs
- /etc/localtime:/etc/localtime:ro
ports:
- "3030:3000"
- "2222:22"
depends_on:
postgres_gitea:
condition: service_healthy
labels:
- "com.centurylabs.watchtower.enable=false"
gitea-runner:
container_name: gitea_runner
image: gitea/act_runner:latest
restart: unless-stopped
networks:
- gitea_internal
environment:
- GITEA_INSTANCE_URL=https://gitea.ginnoir.com
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
- GITEA_RUNNER_NAME=valhalla-management-runner
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:24-bookworm,node-24:docker://node:24-bookworm
volumes:
- /config/gitea/act_runner:/data
- /var/run/docker.sock:/var/run/docker.sock
- /home/ginnoir/valhalla-lab:/valhalla-lab
- /config/caddy:/config/caddy
depends_on:
gitea:
condition: service_started
labels:
- "com.centurylabs.watchtower.enable=false"
registry:
container_name: registry
image: registry:2
restart: unless-stopped
networks:
- registry_internal
- edge
environment:
- REGISTRY_AUTH=htpasswd
- REGISTRY_AUTH_HTPASSWD_REALM=Valhalla Registry
- REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
- REGISTRY_STORAGE_DELETE_ENABLED=true
volumes:
- /storage1/labdata/registry:/var/lib/registry
- /config/registry/auth:/auth:ro
ports:
- "5000:5000"
labels:
- "com.centurylabs.watchtower.enable=false"
registry-ui:
container_name: registry_ui
image: joxit/docker-registry-ui:latest
restart: unless-stopped
networks:
- registry_internal
- edge
environment:
- SINGLE_REGISTRY=true
- REGISTRY_TITLE=Valhalla Registry
- NGINX_PROXY_PASS_URL=http://registry:5000
- SHOW_CONTENT_DIGEST=true
- DELETE_IMAGES=true
depends_on:
- registry
labels:
- "com.centurylabs.watchtower.enable=false"
- Step 2: Add management networks to
portainer-compose.yml
At the bottom of portainer-compose.yml, add:
gitea_internal:
name: gitea_internal
driver: bridge
registry_internal:
name: registry_internal
driver: bridge
- Step 3: Remove moved services from
stacks/dev/docker-compose.yml
Remove these service blocks from stacks/dev/docker-compose.yml:
gitea
gitea-runner
postgres_gitea
registry
registry-ui
Keep code-server, bookstack, mariadb_bookstack, dbx, and all Plane services in stacks/dev/docker-compose.yml.
- Step 4: Move Gitea variables out of
stacks/dev/stack.env
Remove this key from stacks/dev/stack.env after confirming the same value exists in root .env:
GITEA_DB_PASSWORD=...
Keep GITEA_DB_PASSWORD in root .env for management-plane compose interpolation.
- Step 5: Validate raw compose locally
Run:
docker compose -f .\portainer-compose.yml --env-file .\.env config | Out-Null
Expected: the command exits 0 with no error output.
- Step 6: Commit management-plane extraction
Run:
git add portainer-compose.yml stacks/dev/docker-compose.yml stacks/dev/stack.env .env
git commit -m "refactor: move gitea and registry to management plane"
Expected: commit exits 0 and prints a one-line summary for refactor: move gitea and registry to management plane.
Task 5: Convert Remaining App Stack Interpolation to stack.env
Files:
-
Modify:
stacks/dev/docker-compose.yml -
Modify:
stacks/dev/stack.env -
Modify:
stacks/resume/docker-compose.yml -
Modify:
stacks/resume/stack.env -
Step 1: Convert BookStack variable names
In stacks/dev/stack.env, add direct container keys preserving existing values:
APP_KEY=copy the current BOOKSTACK_APP_KEY value
DB_PASS=copy the current BOOKSTACK_DB_PASSWORD value
MYSQL_ROOT_PASSWORD=copy the current BOOKSTACK_DB_ROOT_PASSWORD value
MYSQL_PASSWORD=copy the current BOOKSTACK_DB_PASSWORD value
Replace each copy the current ... value string with the exact current secret value from stacks/dev/stack.env.
In stacks/dev/docker-compose.yml, replace BookStack environment entries that use ${...} with direct names:
- PUID=1000
- PGID=1000
- TZ
- APP_URL=https://docs.ginnoir.com
- APP_KEY
- DB_HOST=mariadb_bookstack
- DB_PORT=3306
- DB_USER=bookstack
- DB_PASS
- DB_DATABASE=bookstack
- AUTH_METHOD=oidc
- AUTH_AUTO_INITIATE=false
- OIDC_NAME=Authentik
- OIDC_ISSUER=https://auth.ginnoir.com/application/o/bookstack/
- OIDC_ISSUER_DISCOVER=true
- OIDC_END_SESSION_ENDPOINT=true
For mariadb_bookstack, replace environment with:
- PUID=1000
- PGID=1000
- TZ
- MYSQL_ROOT_PASSWORD
- MYSQL_DATABASE=bookstack
- MYSQL_USER=bookstack
- MYSQL_PASSWORD
- Step 2: Convert code-server
TZmapping
In stacks/dev/docker-compose.yml, replace:
- TZ=${TZ}
with:
- TZ
- Step 3: Convert Plane variables
In stacks/dev/stack.env, add direct container keys preserving existing values:
SECRET_KEY=copy the current PLANE_SECRET_KEY value
DATABASE_URL=postgresql://plane:copy-the-current-PLANE-DB-PASSWORD-value@postgres_plane:5432/plane
AWS_ACCESS_KEY_ID=copy the current PLANE_MINIO_USER value
AWS_SECRET_ACCESS_KEY=copy the current PLANE_MINIO_PASSWORD value
MINIO_USER=copy the current PLANE_MINIO_USER value
MINIO_PASSWORD=copy the current PLANE_MINIO_PASSWORD value
POSTGRES_PASSWORD=copy the current PLANE_DB_PASSWORD value
MINIO_ROOT_USER=copy the current PLANE_MINIO_USER value
MINIO_ROOT_PASSWORD=copy the current PLANE_MINIO_PASSWORD value
Replace each copy the current ... value string with the exact current secret value from stacks/dev/stack.env. In DATABASE_URL, replace only copy-the-current-PLANE-DB-PASSWORD-value with the actual PLANE_DB_PASSWORD value.
In all Plane service environment blocks, replace ${...} entries with direct variables. For example, plane-api should include:
- SECRET_KEY
- DEBUG=0
- DATABASE_URL
- REDIS_URL=redis://redis_plane:6379/
- CELERY_BROKER_URL=redis://redis_plane:6379/0
- USE_MINIO=1
- AWS_REGION=us-east-1
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- AWS_S3_ENDPOINT_URL=http://plane_minio:9000
- AWS_S3_BUCKET_NAME=uploads
- WEB_URL=https://plane.ginnoir.com
- CORS_ALLOWED_ORIGINS=https://plane.ginnoir.com
- GUNICORN_WORKERS=2
postgres_plane should use:
environment:
POSTGRES_USER: plane
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: plane
Then change it again to list form to avoid compose interpolation:
environment:
- POSTGRES_USER=plane
- POSTGRES_PASSWORD
- POSTGRES_DB=plane
plane-create-bucket should use:
environment:
- MINIO_USER
- MINIO_PASSWORD
plane-minio should use:
environment:
- MINIO_ROOT_USER
- MINIO_ROOT_PASSWORD
- Step 4: Convert resume MinIO OIDC variables
In stacks/resume/stack.env, add direct container keys preserving existing values:
MINIO_IDENTITY_OPENID_CLIENT_ID=copy the current MINIO_OIDC_CLIENT_ID value
MINIO_IDENTITY_OPENID_CLIENT_SECRET=copy the current MINIO_OIDC_CLIENT_SECRET value
Replace each copy the current ... value string with the exact current secret value from stacks/resume/stack.env.
In stacks/resume/docker-compose.yml, replace:
- MINIO_IDENTITY_OPENID_CLIENT_ID=${MINIO_OIDC_CLIENT_ID}
- MINIO_IDENTITY_OPENID_CLIENT_SECRET=${MINIO_OIDC_CLIENT_SECRET}
with:
- MINIO_IDENTITY_OPENID_CLIENT_ID
- MINIO_IDENTITY_OPENID_CLIENT_SECRET
- Step 5: Run the env guard
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-portainer-stack-env.ps1
Expected:
Portainer stack env checks passed.
- Step 6: Validate all stack compose files
Run:
Get-ChildItem .\stacks -Directory | ForEach-Object {
$compose = Join-Path $_.FullName "docker-compose.yml"
$env = Join-Path $_.FullName "stack.env"
if ((Test-Path $compose) -and (Test-Path $env)) {
Push-Location $_.FullName
docker compose --env-file stack.env config | Out-Null
Pop-Location
}
}
Expected: the command exits 0 with no error output.
- Step 7: Commit env model cleanup
Run:
git add stacks/dev/docker-compose.yml stacks/dev/stack.env stacks/resume/docker-compose.yml stacks/resume/stack.env scripts/check-portainer-stack-env.ps1 scripts/portainer-stack-env-exemptions.json
git commit -m "fix: make portainer stacks use stack env only"
Expected: commit exits 0 and prints a one-line summary for fix: make portainer stacks use stack env only.
Task 6: Apply Management Plane and Verify Gitea/Registry Stability
Files:
-
Use:
apply-compose.ps1 -
Use:
portainer-compose.yml -
Use:
.env -
Step 1: Copy registry
htpasswdto valhalla
Run:
ssh -o BatchMode=yes ginnoir@valhalla "mkdir -p /config/registry/auth"
scp "$env:TEMP\valhalla-registry-auth\htpasswd" "ginnoir@valhalla:/tmp/registry-htpasswd"
ssh -o BatchMode=yes ginnoir@valhalla "sudo mv /tmp/registry-htpasswd /config/registry/auth/htpasswd && sudo chmod 640 /config/registry/auth/htpasswd"
Expected: each command exits 0 with no error output.
- Step 2: Push management
.envto valhalla
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\apply-compose.ps1 -EnvFile
Expected:
Pushing .env ...
- Step 3: Apply management plane
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\apply-compose.ps1 -Portainer
Expected:
Applying management plane (portainer, vault, github-runner, watchtower) ...
The status text can still mention github-runner until docs/scripts are updated later.
- Step 4: Verify moved containers
Run:
ssh -o BatchMode=yes -o ConnectTimeout=8 ginnoir@valhalla "docker ps --format '{{.Names}} {{.Status}} {{.Label \"com.docker.compose.project\"}}' | egrep '^(gitea|postgres_gitea|gitea_runner|registry|registry_ui|portainer) '"
Expected:
gitea Up ... valhalla-lab
postgres_gitea Up ... valhalla-lab
gitea_runner Up ... valhalla-lab
registry Up ... valhalla-lab
registry_ui Up ... valhalla-lab
portainer Up ... valhalla-lab
- Step 5: Verify registry auth challenge
Run:
ssh -o BatchMode=yes ginnoir@valhalla "curl -skI https://registry.ginnoir.com/v2/ | head -8"
Expected:
HTTP/2 401
www-authenticate: Basic realm="Valhalla Registry"
docker-distribution-api-version: registry/2.0
- Step 6: Verify Gitea endpoint and SSH
Run:
ssh -o BatchMode=yes ginnoir@valhalla "curl -skI https://gitea.ginnoir.com/ | head -5; ssh -o BatchMode=yes -o StrictHostKeyChecking=no -p 2222 git@localhost 2>&1 | head -5"
Expected:
HTTP/2 200
...
Hi there, ginnoir! You've successfully authenticated...
The SSH greeting username can differ if the key maps to a different Gitea account.
Task 7: Add Gitea Actions for Homelab Caddy Reload
Files:
-
Create:
.gitea/workflows/deploy-caddy.yml -
Keep temporarily:
.github/workflows/deploy.yml -
Modify later:
stacks/proxy/docker-compose.yml -
Modify later:
CLAUDE.md -
Step 1: Create Gitea deploy workflow
Create .gitea/workflows/deploy-caddy.yml:
name: Deploy Caddyfile to valhalla
on:
push:
branches: [main]
paths:
- Caddyfile
- .gitea/workflows/deploy-caddy.yml
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Push Caddyfile and reload Caddy
run: |
cp Caddyfile /config/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
- Step 2: Commit workflow
Run:
git add .gitea/workflows/deploy-caddy.yml
git commit -m "ci: add gitea caddy deploy workflow"
Expected: commit exits 0 and prints a one-line summary for ci: add gitea caddy deploy workflow.
- Step 3: Push to Gitea and GitHub mirror
Run:
git push origin main
Expected:
To https://github.com/ginnoir/homelabstack.git
To https://gitea.ginnoir.com/ginnoir/homelabstack.git
- Step 4: Verify Gitea workflow run
Run:
ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 200 gitea_runner"
Expected:
... Deploy Caddyfile to valhalla ...
... completed ...
- Step 5: Verify Caddy is still serving
Run:
ssh -o BatchMode=yes ginnoir@valhalla "curl -skI https://gitea.ginnoir.com/ | head -5; curl -skI https://registry.ginnoir.com/v2/ | head -8"
Expected:
HTTP/2 200
...
HTTP/2 401
Task 8: Recreate Portainer Stacks Against Gitea With No UI Env Vars
Files:
-
Use:
stacks/*/docker-compose.yml -
Use:
stacks/*/stack.env -
Use:
scripts/check-portainer-stack-env.ps1 -
Step 1: Run env guard before touching Portainer
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-portainer-stack-env.ps1
Expected:
Portainer stack env checks passed.
- Step 2: Create a Gitea read-only PAT for Portainer
In Gitea, create a token for a deployment user or ginnoir with repository read access for ginnoir/homelabstack.
Record these values for Portainer stack creation:
Repository URL: https://gitea.ginnoir.com/ginnoir/homelabstack.git
Repository reference: refs/heads/main
Authentication type: Basic
Username: ginnoir
Password/token: the Gitea read-only PAT created for Portainer
- Step 3: Recreate one low-risk stack first
Use Portainer UI for the first stack to avoid API shape surprises:
Stack name: remote
Repository URL: https://gitea.ginnoir.com/ginnoir/homelabstack.git
Repository reference: refs/heads/main
Compose path: stacks/remote/docker-compose.yml
Authentication: Basic, ginnoir, Gitea read-only PAT
GitOps updates: enabled
Mechanism: polling
Fetch interval: 5 minutes
Environment variables in Portainer UI: empty
Expected:
remote stack deploys successfully
Portainer UI shows no manually declared environment variables
- Step 4: Verify recreated low-risk stack
Run:
ssh -o BatchMode=yes ginnoir@valhalla "docker ps --format '{{.Names}} {{.Status}} {{.Label \"com.docker.compose.project\"}}' | egrep '^(hbbr|hbbs) '"
Expected:
hbbr Up ... remote
hbbs Up ... remote
- Step 5: Recreate remaining app stacks
Repeat the same Portainer Git stack settings for each stack:
proxy stacks/proxy/docker-compose.yml
media stacks/media/docker-compose.yml
foundry stacks/foundry/docker-compose.yml
owncloud stacks/owncloud/docker-compose.yml
resume stacks/resume/docker-compose.yml
famapp stacks/famapp/docker-compose.yml
authentik stacks/authentik/docker-compose.yml
notify stacks/notify/docker-compose.yml
monitoring stacks/monitoring/docker-compose.yml
roms stacks/roms/docker-compose.yml
romhacks stacks/romhacks/docker-compose.yml
share stacks/share/docker-compose.yml
backup stacks/backup/docker-compose.yml
notes stacks/notes/docker-compose.yml
dev stacks/dev/docker-compose.yml
For each stack:
Environment variables in Portainer UI: empty
GitOps updates: enabled
Mechanism: polling
Fetch interval: 5 minutes
Force redeployment: disabled
Re-pull image: enabled for stacks using mutable image tags
- Step 6: Verify stack env values landed from
stack.env
For each recreated stack, inspect one representative container:
ssh -o BatchMode=yes ginnoir@valhalla "docker inspect famapp --format '{{json .Config.Env}}' | jq -r '.[]' | sort | egrep '^(DATABASE_URL|AUTH_URL|MINIO_ROOT_USER)='"
ssh -o BatchMode=yes ginnoir@valhalla "docker inspect bookstack --format '{{json .Config.Env}}' | jq -r '.[]' | sort | egrep '^(APP_KEY|DB_PASS|OIDC_CLIENT_ID)='"
ssh -o BatchMode=yes ginnoir@valhalla "docker inspect plane_api --format '{{json .Config.Env}}' | jq -r '.[]' | sort | egrep '^(SECRET_KEY|DATABASE_URL|AWS_ACCESS_KEY_ID)='"
Expected:
The expected keys are present.
No value contains a trailing carriage return.
No value is empty unless intentionally empty in stack.env.
- Step 7: Verify Portainer is polling Gitea
Make a harmless comment-only commit in stacks/remote/docker-compose.yml, push it, and watch Portainer redeploy remote.
Run:
git commit --allow-empty -m "test: verify portainer gitea polling"
git push origin main
Expected within 5 minutes:
ssh -o BatchMode=yes ginnoir@valhalla "docker ps --format '{{.Names}} {{.Status}} {{.Label \"com.docker.compose.project\"}}' | egrep '^(hbbr|hbbs) '"
The remote container creation time should refresh or Portainer activity should show a GitOps update from Gitea.
Task 9: Move Famapp Image Build and Release to Gitea Actions
Files in C:\Users\MattC\Documents\famapp:
-
Create:
.gitea/workflows/ci.yml -
Create:
.gitea/workflows/release.yml -
Modify:
.release-it.json -
Modify:
README.md -
Modify:
docs/RUNBOOK.md -
Modify:
docs/ENV.md -
Modify:
deploy/README.md -
Modify:
deploy/compose.example.yaml -
Step 1: Create Gitea CI workflow
Create C:\Users\MattC\Documents\famapp\.gitea\workflows\ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
AUTH_SECRET: ci-auth-secret-for-build
NEXT_PUBLIC_APP_URL: http://localhost:3000
- Step 2: Create Gitea release workflow
Create C:\Users\MattC\Documents\famapp\.gitea\workflows\release.yml:
name: Release Image
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive image tags
id: meta
shell: bash
run: |
VERSION="${GITEA_REF_NAME#v}"
MAJOR_MINOR="$(printf '%s' "$VERSION" | awk -F. '{print $1"."$2}')"
{
echo "version=$VERSION"
echo "major_minor=$MAJOR_MINOR"
echo "image=registry.ginnoir.com/ginnoir/famapp"
} >> "$GITEA_OUTPUT"
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_PUSH_PASSWORD }}" | docker login registry.ginnoir.com \
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
--password-stdin
- name: Build image
run: |
docker build \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
-t "${{ steps.meta.outputs.image }}:latest" \
.
- name: Push image
run: |
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
docker push "${{ steps.meta.outputs.image }}:latest"
- Step 3: Add Gitea repository secrets
In Gitea repository ginnoir/famapp, add repository-level Actions secrets:
REGISTRY_PUSH_USERNAME=copy the current homelabstack .env REGISTRY_PUSH_USERNAME value
REGISTRY_PUSH_PASSWORD=copy the current homelabstack .env REGISTRY_PUSH_PASSWORD value
Use the exact values from C:\Users\MattC\Documents\homelabstack\.env when saving the Gitea secrets.
Expected:
Secrets are visible by name in Gitea repository settings.
Secret values are not visible after saving.
- Step 4: Disable GitHub release creation in
.release-it.json
In C:\Users\MattC\Documents\famapp\.release-it.json, replace:
"github": {
"release": true,
"releaseName": "v${version}"
},
with:
"github": {
"release": false
},
- Step 5: Update famapp docs and compose example
Replace GHCR image references with:
registry.ginnoir.com/ginnoir/famapp
Required replacements:
ghcr.io/ginnoir/famapp:latest -> registry.ginnoir.com/ginnoir/famapp:latest
ghcr.io/ginnoir/famapp:v0.x.y -> registry.ginnoir.com/ginnoir/famapp:v0.x.y
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.4.7 -> FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.4.7
- Step 6: Run local famapp checks
Run:
pnpm --dir C:\Users\MattC\Documents\famapp typecheck
pnpm --dir C:\Users\MattC\Documents\famapp lint
pnpm --dir C:\Users\MattC\Documents\famapp format:check
docker build -t registry.ginnoir.com/ginnoir/famapp:local-test C:\Users\MattC\Documents\famapp
Expected:
typecheck passes
lint exits 0
format:check exits 0
docker build completes
- Step 7: Commit famapp workflow migration
Run:
git -C C:\Users\MattC\Documents\famapp add .gitea/workflows/ci.yml .gitea/workflows/release.yml .release-it.json README.md docs/RUNBOOK.md docs/ENV.md deploy/README.md deploy/compose.example.yaml
git -C C:\Users\MattC\Documents\famapp commit -m "ci: publish famapp images to valhalla registry"
Expected: commit exits 0 and prints a one-line summary for ci: publish famapp images to valhalla registry.
- Step 8: Push famapp to Gitea and GitHub mirror
Run:
git -C C:\Users\MattC\Documents\famapp push origin main
Expected:
To https://github.com/ginnoir/famapp.git
To https://gitea.ginnoir.com/ginnoir/famapp.git
Task 10: Publish and Deploy Famapp From the Self-Hosted Registry
Files:
-
Modify:
stacks/famapp/docker-compose.yml -
Step 1: Push a famapp release tag
Run from famapp:
git -C C:\Users\MattC\Documents\famapp tag v0.5.4
git -C C:\Users\MattC\Documents\famapp push origin v0.5.4
Expected:
To https://github.com/ginnoir/famapp.git
To https://gitea.ginnoir.com/ginnoir/famapp.git
- Step 2: Verify image exists in registry
Run:
$envContent = Get-Content C:\Users\MattC\Documents\homelabstack\.env
$pullUser = ($envContent | Where-Object { $_ -match '^REGISTRY_PULL_USERNAME=' }) -replace '^REGISTRY_PULL_USERNAME=', ''
$pullPass = ($envContent | Where-Object { $_ -match '^REGISTRY_PULL_PASSWORD=' }) -replace '^REGISTRY_PULL_PASSWORD=', ''
ssh -o BatchMode=yes ginnoir@valhalla "echo '$pullPass' | docker login registry.ginnoir.com --username '$pullUser' --password-stdin && docker pull registry.ginnoir.com/ginnoir/famapp:latest"
Expected:
Login Succeeded
latest: Pulling from ginnoir/famapp
Status: Downloaded newer image for registry.ginnoir.com/ginnoir/famapp:latest
- Step 3: Point homelab famapp compose at local registry
In stacks/famapp/docker-compose.yml, replace:
image: ghcr.io/ginnoir/famapp:latest
with:
image: registry.ginnoir.com/ginnoir/famapp:latest
- Step 4: Commit famapp compose switch
Run:
git add stacks/famapp/docker-compose.yml
git commit -m "fix(famapp): pull image from valhalla registry"
Expected: commit exits 0 and prints a one-line summary for fix(famapp): pull image from valhalla registry.
- Step 5: Push and wait for Portainer redeploy
Run:
git push origin main
Expected:
To https://github.com/ginnoir/homelabstack.git
To https://gitea.ginnoir.com/ginnoir/homelabstack.git
- Step 6: Verify famapp is using registry image
Run:
ssh -o BatchMode=yes ginnoir@valhalla "docker inspect famapp --format '{{.Config.Image}}'; curl -skI https://fam.ginnoir.com/ | head -5"
Expected:
registry.ginnoir.com/ginnoir/famapp:latest
HTTP/2 200
Task 11: Update Docs and Retire GitHub Runner After Staged Verification
Files:
-
Modify:
CLAUDE.md -
Modify:
AGENTS.md -
Modify:
apply-compose.ps1 -
Modify:
portainer-compose.yml -
Modify:
stacks/proxy/docker-compose.yml -
Modify:
.github/workflows/deploy.yml -
Step 1: Update homelab docs
In CLAUDE.md and AGENTS.md, update deployment channel language:
stacks/domain-name/* -> git push to Gitea main -> Portainer polls every 5 minutes
Caddyfile -> git push to Gitea main -> Gitea Actions copies Caddyfile and reloads Caddy
GitHub -> temporary mirror only during staged migration
Portainer app stacks -> no Portainer UI env vars; use stack.env via env_file
- Step 2: Update proxy comment
In stacks/proxy/docker-compose.yml, replace:
hot-reloaded by the GitHub Actions runner on push
with:
hot-reloaded by the Gitea Actions runner on push
- Step 3: Update apply script management text
In apply-compose.ps1, replace:
Write-Host "Applying management plane (portainer, vault, github-runner, watchtower) ..."
with:
Write-Host "Applying management plane (portainer, vault, gitea, registry, watchtower) ..."
- Step 4: Remove GitHub runner after two successful Gitea deploy cycles
Remove the github-runner service block from portainer-compose.yml only after:
Gitea Actions has reloaded Caddy successfully at least twice.
Portainer has redeployed at least one stack from Gitea polling.
Famapp has been built by Gitea Actions and pulled from registry.ginnoir.com.
- Step 5: Keep
.github/workflows/deploy.ymluntil final GitHub mirror retirement
During the temporary mirror period, disable rather than delete the GitHub workflow by changing triggers to manual only:
name: Deploy to valhalla
on:
workflow_dispatch:
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Disabled
run: echo "Deployment moved to Gitea Actions."
- Step 6: Commit documentation and runner retirement
Run:
git add CLAUDE.md AGENTS.md apply-compose.ps1 portainer-compose.yml stacks/proxy/docker-compose.yml .github/workflows/deploy.yml
git commit -m "docs: document gitea-first homelab deploys"
Expected: commit exits 0 and prints a one-line summary for docs: document gitea-first homelab deploys.
Task 12: Final Verification and Vault Write-Back
Files:
-
Read:
CLAUDE.md -
Read:
AGENTS.md -
Use: Obsidian note
Homelab/_Claude.mdwhen vault MCP is available -
Step 1: Run repo verification
Run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-portainer-stack-env.ps1
docker compose -f .\portainer-compose.yml --env-file .\.env config | Out-Null
git status --short --branch
Expected:
Portainer stack env checks passed.
docker compose config exits 0
## main...origin/main
- Step 2: Verify live management plane
Run:
ssh -o BatchMode=yes ginnoir@valhalla "docker compose -f ~/valhalla-lab/portainer-compose.yml --env-file ~/valhalla-lab/.env ps"
Expected:
portainer Up
vault Up
gitea Up
postgres_gitea Up
gitea_runner Up
registry Up
registry_ui Up
watchtower Up
- Step 3: Verify public/internal endpoints
Run:
ssh -o BatchMode=yes ginnoir@valhalla "curl -skI https://portainer.ginnoir.com/ | head -5; curl -skI https://gitea.ginnoir.com/ | head -5; curl -skI https://registry.ginnoir.com/v2/ | head -8; curl -skI https://fam.ginnoir.com/ | head -5"
Expected:
HTTP/2 200
HTTP/2 200
HTTP/2 401
HTTP/2 200
- Step 4: Verify no app stack relies on Portainer UI env vars
For each recreated Portainer stack, open Portainer UI and confirm:
Stack > Environment variables: empty
Stack > GitOps updates: enabled
Repository URL: https://gitea.ginnoir.com/ginnoir/homelabstack.git
Record any stack with non-empty UI env vars as a blocker and recreate it before continuing.
- Step 5: Write durable findings to Obsidian
When mcp__obsidian__vault_patch or the Obsidian MCP is available, append to Homelab/_Claude.md:
## Gitea-first deploy architecture
- Homelab app stacks now poll `https://gitea.ginnoir.com/ginnoir/homelabstack.git` from Portainer.
- GitHub is only a temporary mirror during the cutover period.
- Gitea, `postgres_gitea`, `gitea_runner`, `registry`, and `registry_ui` are management-plane services, not Portainer app-stack services.
- Portainer app stacks must not define UI environment variables; stack config comes from repo-local `stack.env` via `env_file: stack.env`.
- Famapp images publish to `registry.ginnoir.com/ginnoir/famapp` from Gitea Actions.
- Registry auth uses separate push and pull credentials; Portainer/Docker pulls use the pull credential.
- Step 6: Final commit if vault/docs changed
If only the vault was patched through MCP, no repo commit is needed. If repo docs changed during final verification, run:
git add CLAUDE.md AGENTS.md
git commit -m "docs: record gitea-first deploy verification"
Expected only if docs changed: commit exits 0 and prints a one-line summary for docs: record gitea-first deploy verification.
Rollback Plan
- If Gitea management-plane apply fails:
- Re-apply the previous
portainer-compose.ymlcommit withapply-compose.ps1 -Portainer. - Gitea data is bind-mounted and remains under
/config/giteaand/storage1/labdata/gitea.
- Re-apply the previous
- If registry auth blocks famapp pulls:
- Temporarily set
stacks/famapp/docker-compose.ymlback toghcr.io/ginnoir/famapp:latest. - Push to Gitea/GitHub mirror.
- Recreate Portainer registry credentials before switching back.
- Temporarily set
- If Portainer cannot poll Gitea:
- Keep GitHub mirror stack definitions active.
- Recreate only the affected stack against GitHub until Gitea auth/TLS is fixed.
- If
devstack recreation fails after removing Gitea/registry:- Management-plane Gitea and registry remain independent.
- Restore the previous
stacks/dev/docker-compose.ymlonly for non-moved services if needed.
Self-Review
- Spec coverage:
- Gitea-first migration is covered by Tasks 4, 7, 8, 11, and 12.
- Temporary GitHub mirror is covered by Tasks 7, 8, 9, 10, and 11.
- Docker registry setup and famapp image publishing are covered by Tasks 3, 4, 9, and 10.
- Portainer
stack.envenforcement is covered by Tasks 2, 5, 8, and 12. - Work-repo remote protection is covered by Task 1.
- Red-flag scan:
- Secret values are intentionally generated at execution time by
scripts/new-registry-auth.ps1; plan text names exact keys and commands without embedding secrets. - No implementation step depends on an unnamed file or undefined command.
- Secret values are intentionally generated at execution time by
- Type and name consistency:
- Registry image path is consistently
registry.ginnoir.com/ginnoir/famapp. - Gitea repository path is consistently
https://gitea.ginnoir.com/ginnoir/homelabstack.git. - Guard script and exemption file paths are consistent across tasks.
- Registry image path is consistently