Skip to content

Stack Reuse Guide

Read this BEFORE scaffolding a new stack. Answers: should this new service reuse an existing component, or stand up its own? Goal: prevent one-database-per-stack sprawl and duplicate services doing the same job.

When to read

Open this doc whenever you:

  • Add a new stack via the stack-manager skill
  • Add a new service that needs a database, queue, cache, vector store, file storage, or auth
  • Plan to migrate an app from a self-hosted DB to a shared one (or vice versa)
  • Review whether two stacks are doing the same job

For the how of adding a stack, see deployment-guide.md and the stack-manager skill. This doc only covers the should we reuse decision.

Decision tree

graph TD
    ROOT{"New service needs
persistence / stateful backend?"} ROOT -->|NO — pure compute, gateway, transformer| STAND["Standalone stack is fine
e.g. caddy, gluetun, openclaw"] ROOT -->|YES → walk each layer| LAYERS subgraph LAYERS["Walk each layer"] DB{"DATABASE?"} DB -->|Default| PIGSTY["reuse pigsty-postgres :5432 / :5439
dedicated DB + user"] DB -->|Immich-only| IMMICHDB["keep postgres-immich :5437
do NOT migrate"] DB -->|Exotic extension| DEDDB["dedicated DB stack, document why"] DB -->|Vendor bundled PG| BUNDLED["accept bundled DB"] REST{"REST API surface?"} REST -->|Simple CRUD, no business logic| PGREST["reuse PostgREST
PGRST_db_uri at app DB"] REST -->|Public API + rate limiting / SDKs| REALBE["write a real backend"] REST -->|App has backend / ORM| SKIPREST["skip PostgREST"] GQL{"GRAPHQL?"} GQL -->|Default| PGGRAPH["pg_graphql extension on pigsty
CREATE EXTENSION, no new service"] GQL -->|Subscriptions / Hasura-grade| HASURA["dedicated Hasura stack"] VEC{"VECTOR / SEMANTIC SEARCH?"} VEC -->|Data in Postgres| PGVEC["pgvector + pg_search on pigsty. Reuse"] VEC -->|High-throughput standalone| QDRANT["qdrant stack :6333"] VEC -->|Third vector DB| NOVEC["Do NOT without recorded reason"] FILE{"FILE STORAGE?"} FILE -->|S3-compatible API| OPENLIST["openlist :5244, S3 :5246"] FILE -->|Bundled storage| KEEPBUNDLE["keep bundled (Immich, Nextcloud)"] FILE -->|MinIO / SeaweedFS| NOMINIO["Only if openlist can't do the job"] QUEUE{"QUEUE / MESSAGE BUS?"} QUEUE -->|MQTT| MOSQ["mosquitto :1883"] QUEUE -->|Kafka-ish streaming| REDPANDA["redpanda :9092"] QUEUE -->|Lightweight task queue| PGQUEUE["pigsty LISTEN/NOTIFY or pgmq"] QUEUE -->|Real cache TTL/LRU| REDIS["redis :6379"] AUTH{"AUTH?"} AUTH -->|Human passwords / SMB / device| VAULT["vaultwarden (bw / rbw CLI)"] AUTH -->|App JWT by PostgREST| PGRESTAUTH["PostgREST handles it"] AUTH -->|Own OAuth / passkey / session| BAUTH["Better Auth library inside app"] AUTH -->|GoTrue / Keycloak / Authelia| NOAUTH["Do NOT without recorded reason"] GW{"API GATEWAY / REVERSE PROXY?"} GW -->|Always| CADDY["reuse caddy
Add route in stacks/networking/caddy/conf/"] GW -->|Traefik / Nginx / Kong| NOPROXY["Never add alongside caddy"] WF{"WORKFLOW / AUTOMATION?"} WF -->|Visual / scheduled workflows| N8N["n8n"] WF -->|AI agent routing| OPENCLAW["openclaw via LiteLLM"] WF -->|Code DAGs| AIRFLOW["airflow when justified"] MON{"MONITORING?"} MON -->|Metrics| MIMIRG["mimir + grafana"] MON -->|Logs| LOKIP["loki via promtail / vector"] MON -->|Traces| TEMPOO["tempo via otel-collector"] MON -->|Uptime| UPTIMEK["uptime-kuma + sync-uptime-kuma.py"] end

Shared services catalog

What already exists and is meant to be reused. One row per capability — do not add a second service for a capability that's already covered.

Capability Service Where Notes
Default Postgres pigsty-postgres stacks/database/pigsty/ (5432) ParadeDB. Has pg_search, pgvector, pg_cron, pg_trgm.
Supabase (full BaaS) supabase-* (12 containers incl. Tailscale sidecar) stacks/database/supabase/ Self-hosted Supabase. Bundled PG 17, Auth, REST, Realtime, Storage, Functions, Studio. Separate DB cluster from pigsty. Connection: supabase.tail1137c.ts.net:5432 (pooler) / :6543 (txn pool) / :443 (HTTPS). See connection playbook.
Immich Postgres (carve-out) postgres-immich stacks/database/postgres/ (5437) Immich-only. VectorChord. Do NOT touch.
DB dashboard postgresus stacks/database/postgresus/ Point at pigsty for general use.
REST-from-PG postgrest (standalone) (removed 2026-07-02) Replaced by Supabase's bundled PostgREST (supabase-rest). For apps needing REST-from-PG outside Supabase, add a sidecar in that app's compose.
GraphQL-from-PG pg_graphql ext. on pigsty CREATE EXTENSION pg_graphql; — no new container.
Vector (in-PG) pgvector + pg_search on pigsty HNSW index + BM25 hybrid search.
Vector (standalone) qdrant stacks/database/qdrant/ Use when PG isn't the right home for the workload.
Redis (cache + queue) redis stacks/database/redis/ Cache, rate limit, lightweight pub/sub.
Search (dedicated) meilisearch stacks/database/meilisearch/ App-facing typo-tolerant search.
MQTT broker mosquitto stacks/networking/mosquitto/ IoT / sensor traffic.
Streaming redpanda stacks/database/redpanda/ or storage Kafka-compatible.
S3-compatible storage openlist stacks/storage/openlist/ HTTP 5244, S3 5246.
Graph DB memgraph stacks/database/memgraph/ When relationships are the data.
ClickHouse clickhouse stacks/database/clickhouse/ OLAP / log analytics.
Reverse proxy / gateway caddy stacks/networking/caddy/ All HTTP entry.
DNS adguard-home + replica stacks/networking/adguard-home/ Primary + replica + sync.
Workflow engine n8n stacks/orchestration/n8n/ Visual automation.
LLM proxy litellm stacks/llm/litellm/ Also MCP proxy.
AI agent gateway openclaw stacks/llm/openclaw/ Telegram + WebSocket channels.
Secrets (human) vaultwarden stacks/applications/vaultwarden/ bw / rbw CLI.
Monitoring — metrics grafana + mimir stacks/monitoring/
Monitoring — uptime uptime-kuma stacks/monitoring/uptime-kuma/ Synced from homepage services.yaml.

Anti-patterns to reject during review

  1. One DB per stack when the app could use pigsty. Exception: bundled Postgres in the vendor image (Immich, some versions of Gitea). Document the exception in the stack's compose.yaml header comment.
  2. Standing up a second reverse proxy. Caddy is the only HTTP entrypoint. Adding Traefik/Nginx/Kong "for one app" fragments routing and TLS.
  3. Standing up a second vector DB. pgvector (in pigsty) and Qdrant cover the spectrum. A third needs a written reason.
  4. Storing secrets in compose.env. All secrets go through Komodo Variables ([[VAR]] in TOML, ${VAR} in compose). See stack-manager SKILL.md → "Env var sync (MANDATORY — TWO-TIER RULE)".
  5. Deploying an auth service (GoTrue / Keycloak / Authelia) when Better Auth fits. Better Auth is a library, not a service — no extra container, no separate user store.
  6. Using LAN IP for service-to-service connections. LAN IPs can change via DHCP. Prefer Tailscale domain or Tailscale IP (100.x — stable, never changes). Use Tailscale sidecar pattern (below) for services that need stable addresses accessible from any device.
  7. Adding a container with network_mode: host to dodge the reverse-proxy network. Almost always wrong; declare the network explicitly.
  8. Using generic service names on shared Docker networks. Names like rest, auth, db, storage collide with other stacks (observed: iceberg's rest service broke Supabase Kong routing). Always prefix: supabase-rest, iceberg-rest, etc.

Reusable patterns

Tailscale sidecar (expose service over Tailscale)

For services that need stable, encrypted access from any Tailscale device (phone, laptop off-LAN, development server):

# In your stack's compose.yaml — add a sidecar service
tailscale-{service}:
  image: tailscale/tailscale:latest
  hostname: { service }
  cap_add: [NET_ADMIN]
  devices: [/dev/net/tun:/dev/net/tun]
  environment:
    TS_AUTHKEY: ${TS_AUTHKEY}
    TS_HOSTNAME: { service }
    TS_STATE_DIR: /var/lib/tailscale
    TS_SERVE_CONFIG: /config/serve.json
    TS_USERSPACE: "false"
    TS_ACCEPT_DNS: "false" # keep Docker DNS for container name resolution
  dns: [127.0.0.11, 100.100.100.100] # Docker first, Tailscale second
  volumes:
    - /mnt/user/appdata/{service}/tailscale:/var/lib/tailscale
    - /mnt/user/appdata/{service}/config/tailscale/serve.json:/config/serve.json:ro
  networks: [default]

serve.json (declarative config — auto-provisions TLS cert via Tailscale):

{
  "TCP": {
    "443": { "HTTPS": true },
    "5432": { "TCPForward": "{service}-pooler:5432" }
  },
  "Web": {
    "{service}.tail1137c.ts.net:443": {
      "Handlers": {
        "/": { "Proxy": "http://{service}-kong:8000" }
      }
    }
  }
}

Key gotchas learned:

  • TS_ACCEPT_DNS: "false" + dual dns: — container needs Docker DNS (127.0.0.11) to resolve other containers by name. Tailscale DNS (100.100.100.100) as secondary.
  • TCPForward (NOT TCP) — correct field name in serve config JSON. Using "TCP" silently produces empty config.
  • Sidecar auto-provisions Let's Encrypt wildcard cert for *.ts.net — no manual cert management.
  • Resource cost: ~30-50MB RAM idle per sidecar. 10 services = 500MB — fine on unraid.
  • Reuses TS_AUTHKEY Komodo Variable — no per-service authkey needed.

Pattern scale: add 1 sidecar per service that needs Tailscale exposure. Current sidecars: tailscale-supabase. Future: tailscale-gitea, tailscale-n8n, etc. Each gets its own Tailscale hostname + auto-TLS.

Pre-flight checklist

Sau khi đi qua decision tree + shared services catalog ở trên, làm theo deploy-checklist.md §1 + §2 (single source of truth cho pre-flight, naming, secrets, DNS, file ownership, deploy commands, docs/dashboard updates).

Decision tree và catalog bên trên vẫn là参考 duy nhất cho câu hỏi reuse hay standalone — phần checklist thuần túy đã được consolidate.

Worked example: PostgREST standalone → Supabase migration (2026-07)

Trigger: wanted Supabase-like REST-from-PG without self-hosting the whole Supabase stack.

First attempt (standalone PostgREST): Created stacks/networking/postgrest/ exposing port 3001, pointing at a postgrest_api database on pigsty. Hit 4 successive bugs in 1 session: docker image uid 99 → uid 1000 → root → no /etc/passwd (distroless), then PostgREST v12+ silently ignoring lowercase env vars. Each fixable, but revealed the real limitation: PostgREST exposes ONE database per instance. N apps needing REST = N instances. Did not solve the unified-API goal.

Pivot to full Supabase self-host: Standalone removed. Supabase stack (stacks/database/supabase/, 11 containers) now provides the integrated experience: PG 17 + Auth (GoTrue) + PostgREST + Realtime + Storage + Edge Functions + Studio dashboard, all sharing JWT + RLS. Bundled Postgres 17 (NOT pigsty — needs Supabase-specific extensions like pgsodium, supabase_admin, vault).

Decisions:

  • Bundled PG 17 over reusing pigsty (extensions incompatible).
  • Keep Kong internal; join Kong to reverse-proxy network so Caddy (macvlan) can reach via container name supabase-kong:8000 (macvlan can't reach host IP).
  • All env var names follow Supabase upstream conventions (POSTGRES*PASSWORD, JWT_SECRET, ...) but Komodo Variables use SUPABASE*\*prefix to avoid collision with other stacks. Mapping happens insupabase.toml.
  • pre_deploy copies vendored configs (SQL migrations, Kong declarative config, pooler.exs, edge-runtime entrypoint) from repo to /mnt/user/appdata/supabase/config/ because docker daemon resolves bind mount source paths from HOST view, not through periphery container's bind mounts (same pattern as caddy).
  • SMTP deferred-verified — ProtonMail Bridge SMTP may not work server-to-server.

Result: http://supabase.home serves Studio dashboard. 11/11 containers healthy. Caddy reverse-proxies via container name. Anonymous key + service role key generated as proper HS256 JWTs signed with the same JWT_SECRET.

Lessons for next time:

  • Self-host Supabase expects a fresh Postgres data dir; failed deploys leave partial state that skips init scripts on retry. Wipe /mnt/user/appdata/ supabase/db/data/ between failed deploy attempts.
  • GoTrue v2.189+ requires explicit GOTRUE_JWT_KEYS=[] even when using only symmetric HS256. Empty/unset panics with "unexpected end of input".
  • GoTrue GOTRUE_URI_ALLOW_LIST glob-compiles each entry — JSON array syntax [...] panics glob.MustCompile because [ opens a character class. Use comma-separated URLs.
  • Realtime AES-128 requires exactly 16-byte key. Supavisor Vault requires 32-byte. Generate with the right size, not base64 of random bytes.
  • Supavisor (pooler) tries ulimit -n 100000 on boot — needs explicit ulimits: nofile: 100000 in compose or it spams EPERM.