Findings из сессии 2026-05-22 (user корректировка: «через портейнер!»): deploy через ssh + docker compose up — anti-pattern, breaks ops surface homogeneity. Все stacks на VDS должны быть Portainer-managed. Retro-migrated 12 stacks (board-viewer, ntfy, registry, verdaccio, gitea, owncloud, postgres, mongo, mariadb, redis, minio-imgproxy, mssql) через JWT auth + /api/stacks/create/standalone/string API. Migration script template в новой wiki концепции. Skipped traefik+portainer (management plane recreate ломает access). Gotchas закреплены в portainer-stack-management-vds.md §Gotchas: - env_file: .env requires strip + env array в API payload - ./ binds для sibling files (nginx.conf) absolutize → /opt/stacks/<>/ - docker login registry.kzntsv.site обязателен на VDS host - container recreate downtime ~30-60s/stack (sequential) - Portainer working_dir /data/compose/<id>/ не /opt/stacks/<name>/ CLAUDE.md новое правило: VDS docker stacks → Portainer canonical. STATUS header updated; board-viewer-vds-deploy closed 🟢 как часть sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.7 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 перед всем остальным.
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.