Files
admin/.wiki/concepts/portainer-stack-management-vds.md
vitya d99602894f ops(vds): Portainer-canonical rule + migrate 12 ad-hoc stacks
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>
2026-05-22 16:47:16 +03:00

7.7 KiB
Raw Blame History

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

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

  1. 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 через 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/<id>/nginx.conf где файла нет → container mount fails. Fix: абсолютизировать bind через sed s|\./|$DIR/|g. Файлы остаются на disk в /opt/stacks/<stack>/, 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/<name> (ad-hoc) на /data/compose/<id> (Portainer-managed). Сохранённые /opt/stacks/<name>/.env остаются на disk — лишь для backup/reference, не используются compose runtime.
  7. Volumes preserved across migration — named volumes (docker compose down без -v) и binds (/opt/stacks/<name>/data) сохраняются. Data layer не теряется при ad-hoc→Portainer переезде.
  8. Management plane skiptraefik и 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 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

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.