Перекат stack 16 на registry.kzntsv.site/pilonuxt:19a4a84 (build на workstation, push, Portainer PUT pullImage). Smoke на проде через --resolve: description непустой, brand=Пилорама 98, offers→ hasMerchantReturnPolicy/returnPolicyCategory=MerchantReturnNotPermitted, прежние offers-поля целы, регрессий нет. Отчёт в inbox pilonuxt. Wiki ingest: portainer-stack-management-vds — раздел Stack redeploy (новый тег) + gotcha #9 (PS 5.1 Invoke-RestMethod декодит /file как ISO-8859-1 → mojibake кириллицы → PUT падает YAML; fix байты+UTF8) + #10 (пустой env). index/log обновлены. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
Portainer stack management on vds-kzntsv
Canonical rule: all docker-compose stacks на VDS управляются через Portainer (https://portainer.vds.kzntsv.site). Ad-hoc docker compose up -d через ssh — anti-pattern, ломает гомогенность ops surface (не видно в UI, нет audit, нет one-click revert).
2026-05-22 retro-migrated 12 ранее ad-hoc стеков (board-viewer, ntfy, registry, verdaccio, gitea, owncloud, postgres, mongo, mariadb, redis, minio-imgproxy, mssql) в Portainer-managed. Skipped traefik + portainer (management plane — recreate ломает access).
Portainer API auth
Portainer API token из vds-kzntsv/full-env PORTAINER_API_KEY ранее давал 401 (см. owncloud-vds-deploy follow-up; требует regen через UI). Workaround — JWT через admin password:
JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth \
-H "Content-Type: application/json" \
-d '{"username":"vitya","password":"Pryakhin9-VDS-2026"}' | jq -r .jwt)
Pass-store: pass show vds-kzntsv/full-env (full env file со всеми creds).
Migration script (ad-hoc → Portainer-managed)
#!/bin/bash
# /tmp/portainer-migrate.sh — runs on VDS
set -e
STACK="$1" # e.g. "mssql"
DIR="$2" # e.g. "/opt/stacks/databases/mssql"
PORTAINER_URL="https://portainer.vds.kzntsv.site"
JWT=$(curl -ksS -X POST "$PORTAINER_URL/api/auth" -H "Content-Type: application/json" \
-d '{"username":"vitya","password":"<from vds-kzntsv/full-env>"}' | jq -r .jwt)
# Transform compose: strip env_file directives (Portainer передаёт env через API array),
# absolutize ./ paths (Portainer-managed working_dir = /data/compose/<id>/, не stack dir)
COMPOSE=$(sudo cat "$DIR/docker-compose.yml" | sed \
-e '/^\s*env_file:/d' \
-e '/^\s*-\s*\.env\s*$/d' \
-e "s|\\./|$DIR/|g")
# Parse .env into array
ENV_ARRAY="[]"
if sudo test -f "$DIR/.env"; then
ENV_ARRAY=$(sudo cat "$DIR/.env" | grep -vE '^\s*(#|$)' | jq -Rs '
split("\n") | map(select(length>0) | split("=") | {name: .[0], value: (.[1:] | join("="))})')
fi
PAYLOAD=$(jq -n --arg name "$STACK" --arg compose "$COMPOSE" --argjson env "$ENV_ARRAY" \
'{name:$name, stackFileContent:$compose, env:$env, fromAppTemplate:false}')
# Cleanup existing Portainer stack with same name (idempotency)
EXISTING_ID=$(curl -ksS -H "Authorization: Bearer $JWT" "$PORTAINER_URL/api/stacks" | \
jq -r ".[] | select(.Name==\"$STACK\") | .Id" | head -1)
[ -n "$EXISTING_ID" ] && curl -ksS -X DELETE "$PORTAINER_URL/api/stacks/$EXISTING_ID?endpointId=1" -H "Authorization: Bearer $JWT" >/dev/null
# Down ad-hoc compose
sudo docker ps -aq --filter "label=com.docker.compose.project=$STACK" | grep -q . && \
(cd "$DIR" && sudo docker compose down 2>&1 | tail -2)
# Create via Portainer
curl -ksS -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=1" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" --data "$PAYLOAD"
Gotchas
env_file: .envломает Portainer string-mode — Portainer не материализует.envфайл в/data/compose/<id>/. Compose pull fails:env file /data/compose/<id>/.env not found. Fix:sed '/^\s*env_file:/d'+ передавать env через APIenvarray.- Sibling files (
nginx.conf, certs, scripts) не uploadятся — string-mode не подхватывает sibling files../nginx.conf:/etc/nginx/conf.d/default.confPortainer resolves в/data/compose/<id>/nginx.confгде файла нет → container mount fails. Fix: абсолютизировать bind через seds|\./|$DIR/|g. Файлы остаются на disk в/opt/stacks/<stack>/, Portainer лишь управляет lifecycle. - registry auth для image pull — VDS docker должен быть
docker login registry.kzntsv.siteдоdocker compose pull. Иначеpull access denied. Login persistent в/root/.docker/config.jsonпосле первого раза. - Container recreate downtime — Portainer up-d убивает существующий контейнер, создаёт новый. Для prod stacks (mssql, owncloud, gitea) ~30-60s простой. Connections retry прозрачно если клиент resilient (IIS reconnects).
- Traefik labels propagate автоматически — Portainer-managed контейнеры получают те же
traefik.*labels из compose, traefik docker provider их видит через socket event. Никаких отдельных шагов не нужно. com.docker.compose.project.working_dirменяется с/opt/stacks/<name>(ad-hoc) на/data/compose/<id>(Portainer-managed). Сохранённые/opt/stacks/<name>/.envостаются на disk — лишь для backup/reference, не используются compose runtime.- Volumes preserved across migration — named volumes (
docker compose downбез-v) и binds (/opt/stacks/<name>/data) сохраняются. Data layer не теряется при ad-hoc→Portainer переезде. - Management plane skip —
traefikиportainerсами через себя нельзя безопасно recreate (теряется access). Оставлены ad-hoc; их docker-compose.yml в/opt/stacks/{traefik,portainer}/. Future: bootstrap script для cold-start ставит их first перед всем остальным. - PowerShell 5.1 коррапит кириллицу при round-trip stack-file через API —
Invoke-RestMethodнаGET /api/stacks/<id>/fileдекодит тело как ISO-8859-1 (нетcharsetв Content-Type ответа), UTF-8 кириллица в комментариях compose превращается в mojibake (Боевой→Боевой). ОбратныйPUTшлёт mojibake → docker compose загрузчик падаетyaml: line N: could not find expected ':'на строке с битым комментарием. На диске исходный файл корректен — портит именно round-trip. Fix (PS 5.1): качать байтамиInvoke-WebRequest -OutFile $tmp→Get-Content $tmp -Raw -Encoding UTF8 | ConvertFrom-Json, а тело PUT слать UTF-8-байтами:$bytes=[Text.Encoding]::UTF8.GetBytes($json); Invoke-RestMethod -Body $bytes -ContentType 'application/json; charset=utf-8'. (pilorama98/pilonuxt redeploy 2026-06-17.) - Пустой
envв PUT-payload — Portainer ждётenv: []([]portainer.Pair). PS@{env=@()}|ConvertTo-Jsonсхлопывает пустой массив →Invalid request payload. Fix: подставить literal через плейсхолдер —(... | ConvertTo-Json) -replace '"__ENV__"','[]'.
Stack redeploy (existing stack, новый image tag)
Перекат уже-managed стека на новый тег образа (НЕ создание). Применялось для pilonuxt (stack Id 16) на redeploy-pilonuxt-gsc-* 2026-06-16/17. Образ собирается/пушится на workstation (≈400 МБ влезает; multi-GB → собирать на VDS, см. memory registry-large-push-499-build-on-vds), затем:
# 1. JWT
$jwt = (Invoke-RestMethod -Method Post -Uri 'https://portainer.vds.kzntsv.site/api/auth' `
-ContentType 'application/json' -Body (@{username='vitya';password='<vds-kzntsv/full-env>'}|ConvertTo-Json)).jwt
$h = @{ Authorization = "Bearer $jwt" }
# 2. GET текущий compose БАЙТАМИ (gotcha #9), подменить тег
$tmp = "$env:TEMP\stack.json"
Invoke-WebRequest -Headers $h -Uri 'https://portainer.vds.kzntsv.site/api/stacks/16/file' -OutFile $tmp
$new = ((Get-Content $tmp -Raw -Encoding UTF8 | ConvertFrom-Json).StackFileContent) -replace 'pilonuxt:OLD','pilonuxt:NEW'
# 3. PUT с pullImage:true (форсит свежий pull нового тега), env=[] через плейсхолдер, тело UTF-8-байтами
$json = (@{stackFileContent=$new; env='__ENV__'; prune=$false; pullImage=$true}|ConvertTo-Json -Depth 10) -replace '"__ENV__"','[]'
Invoke-RestMethod -Method Put -Headers $h -ContentType 'application/json; charset=utf-8' `
-Body ([Text.Encoding]::UTF8.GetBytes($json)) -Uri 'https://portainer.vds.kzntsv.site/api/stacks/16?endpointId=1'
Verify: контейнер на новом образе — ops.docker.ps name=<stack> (vds-ops MCP) показывает image: registry.kzntsv.site/<name>:NEW. Прод-smoke мимо LAN-DNS воркстейшна — curl --resolve <host>:443:89.253.255.94 (иначе резолвится локальная копия, см. memory workstation-lan-dns-serves-local-cms-copy); краулить реальные nav-страницы + data-endpoint, не 2 роута (smoke-crawl-real-pages-not-two-routes).
Предусловие pull: registry.kzntsv.site зарегистрирован в Portainer как Custom registry Id 1 (иначе pull access denied / no basic auth). См. memory portainer-vds-needs-registry-registered.
Stack inventory (2026-05-22, Portainer Ids)
| Id | Name | Dir | Note |
|---|---|---|---|
| 3 | board-viewer | /opt/stacks/board-viewer |
basicauth Traefik |
| 4 | ntfy | /opt/stacks/ntfy |
|
| 5 | registry | /opt/stacks/registry |
+ registry-ui |
| 6 | verdaccio | /opt/stacks/verdaccio |
npm registry |
| 7 | gitea | /opt/stacks/gitea |
git.kzntsv.site |
| 8 | owncloud | /opt/stacks/owncloud |
oCIS 7.1, mem_limit 1G |
| 9 | postgres | /opt/stacks/databases/postgres |
TLS via traefik raw-TCP |
| 10 | mongo | /opt/stacks/databases/mongo |
TLS via traefik raw-TCP |
| 11 | mariadb | /opt/stacks/databases/mariadb |
TLS via traefik raw-TCP |
| 12 | redis | /opt/stacks/databases/redis |
TLS via traefik raw-TCP |
| 13 | minio-imgproxy | /opt/stacks/storage/minio-imgproxy |
MinIO 2025-09 + imgproxy + nginx |
| 14 | mssql | /opt/stacks/databases/mssql |
Express 2022, traefik TCP :1433 |
Non-Portainer (management plane):
traefik—/opt/stacks/traefik/, ad-hoc composeportainer—/opt/stacks/portainer/, ad-hoc composevds-docker-proxy-ro+vds-ops-mcp— ad-hoc из/tmp(часть synology/vds-ops setup)
Smoke post-migration
JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth -H "Content-Type: application/json" -d '{"username":"vitya","password":"..."}' | jq -r .jwt)
# All 12 stacks visible in Portainer
curl -ksS -H "Authorization: Bearer $JWT" https://portainer.vds.kzntsv.site/api/stacks | jq -r '.[].Name'
# Critical service smokes
curl -ksS -o /dev/null -w "%{http_code}\n" https://git.kzntsv.site/
curl -ksS -o /dev/null -w "%{http_code}\n" https://owncloud.kzntsv.site/
curl -ksS -u "viewer:..." https://board.kzntsv.site/ -o /dev/null -w "%{http_code}\n"
# MSSQL external (via PowerShell since git-bash mangles path):
docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S 'mssql.kzntsv.site,1433' -U snolla -P '...' -d MoreThenCms -C -No -Q "SELECT 1"
Все 8 IIS prod hosts (emspb / snolla / on.snolla / pilorama98 / labtools.{ru,pro} / tandemmebel / kupimknigi) → 200 через VDS MSSQL после migration.