--- title: Portainer stack management on vds-kzntsv — canonical pattern status: live tags: [vds, portainer, docker-compose, ops] related: [[vds-kzntsv]], [[mssql-on-vds]], [[minio-imgproxy-on-vds]] updated: 2026-06-17 --- # 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: ```bash 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) ```bash #!/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":""}' | jq -r .jwt) # Transform compose: strip env_file directives (Portainer передаёт env через API array), # absolutize ./ paths (Portainer-managed working_dir = /data/compose//, не 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" ``` ## Convention: `mem_limit` на app-стеках (обязательно) **Каждый app/site-стек несёт `mem_limit`.** Без него cgroup-`limit` = вся память хоста (12.9 GiB) → одна течь в контейнере может съесть весь бокс, а не упереться в свой потолок. С `restart: unless-stopped` упор в лимит = OOM-kill контейнера + авто-подъём (изоляция вместо каскада на всю машину). - **standalone-стек (не swarm) → ключ `mem_limit`**, НЕ `deploy.resources.limits.memory` (тот игнорится вне swarm). - Значение = ~2.5–5× наблюдаемого пика. snolla-сайты (Node SSR, baseline ~100–200 MB) → **`512m`**. Прочерк-пример: owncloud oCIS → `1g`. - Ставить в момент создания/правки стека — не откладывать. Проверка постфактум: `ops.docker.stats ` поле `limit` == заданному (512m = 536870912), не 13890813952. - **Синхронизировать обе копии**: боевой стек в Portainer (PUT) И source-of-truth compose в `admin/host-stacks/vds-kzntsv/.compose.yml`. Иначе следующий redeploy из гита откатит лимит. Применено 2026-07-05 ко всему тиражу snolla (labtools.ru/17, emspb/18, labtools.pro/19, tandemmebel/20, kupimknigi/21) → все `512m`. ## Gotchas 1. **`env_file: .env` ломает Portainer string-mode** — Portainer не материализует `.env` файл в `/data/compose//`. Compose pull fails: `env file /data/compose//.env not found`. Fix: `sed '/^\s*env_file:/d'` + передавать env через API `env` array. 2. **Sibling files (`nginx.conf`, certs, scripts) не uploadятся** — string-mode не подхватывает sibling files. `./nginx.conf:/etc/nginx/conf.d/default.conf` Portainer resolves в `/data/compose//nginx.conf` где файла нет → container mount fails. Fix: абсолютизировать bind через sed `s|\./|$DIR/|g`. Файлы остаются на disk в `/opt/stacks//`, Portainer лишь управляет lifecycle. 3. **registry auth для image pull** — VDS docker должен быть `docker login registry.kzntsv.site` до `docker compose pull`. Иначе `pull access denied`. Login persistent в `/root/.docker/config.json` после первого раза. 4. **Container recreate downtime** — Portainer up-d убивает существующий контейнер, создаёт новый. Для prod stacks (mssql, owncloud, gitea) ~30-60s простой. Connections retry прозрачно если клиент resilient (IIS reconnects). 5. **Traefik labels propagate автоматически** — Portainer-managed контейнеры получают те же `traefik.*` labels из compose, traefik docker provider их видит через socket event. Никаких отдельных шагов не нужно. 6. **`com.docker.compose.project.working_dir`** меняется с `/opt/stacks/` (ad-hoc) на `/data/compose/` (Portainer-managed). Сохранённые `/opt/stacks//.env` остаются на disk — лишь для backup/reference, не используются compose runtime. 7. **Volumes preserved across migration** — named volumes (`docker compose down` без `-v`) и binds (`/opt/stacks//data`) сохраняются. Data layer не теряется при ad-hoc→Portainer переезде. 8. **Management plane skip** — `traefik` и `portainer` сами через себя нельзя безопасно recreate (теряется access). Оставлены ad-hoc; их docker-compose.yml в `/opt/stacks/{traefik,portainer}/`. Future: bootstrap script для cold-start ставит их first перед всем остальным. 9. **PowerShell 5.1 коррапит кириллицу при round-trip stack-file через API** — `Invoke-RestMethod` на `GET /api/stacks//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.) 10. **Пустой `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`), затем: ```powershell # 1. JWT $jwt = (Invoke-RestMethod -Method Post -Uri 'https://portainer.vds.kzntsv.site/api/auth' ` -ContentType 'application/json' -Body (@{username='vitya';password=''}|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=` (vds-ops MCP) показывает `image: registry.kzntsv.site/:NEW`. Прод-smoke мимо LAN-DNS воркстейшна — `curl --resolve :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 compose - `portainer` — `/opt/stacks/portainer/`, ad-hoc compose - `vds-docker-proxy-ro` + `vds-ops-mcp` — ad-hoc из `/tmp` (часть synology/vds-ops setup) ## Smoke post-migration ```bash 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.