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-managerskill - 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¶
- 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.yamlheader comment. - Standing up a second reverse proxy. Caddy is the only HTTP entrypoint. Adding Traefik/Nginx/Kong "for one app" fragments routing and TLS.
- Standing up a second vector DB. pgvector (in pigsty) and Qdrant cover the spectrum. A third needs a written reason.
- 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)". - 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.
- 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.
- Adding a container with
network_mode: hostto dodge the reverse-proxy network. Almost always wrong; declare the network explicitly. - Using generic service names on shared Docker networks. Names like
rest,auth,db,storagecollide with other stacks (observed: iceberg'srestservice 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"+ dualdns:— container needs Docker DNS (127.0.0.11) to resolve other containers by name. Tailscale DNS (100.100.100.100) as secondary.TCPForward(NOTTCP) — 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_AUTHKEYKomodo 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-proxynetwork so Caddy (macvlan) can reach via container namesupabase-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_LISTglob-compiles each entry — JSON array syntax[...]panicsglob.MustCompilebecause[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 100000on boot — needs explicitulimits: nofile: 100000in compose or it spams EPERM.
Related¶
- code-standards.md — Database connection conventions, networking rules.
- deployment-guide.md — How to add a stack once reuse is decided.
- system-architecture.md — Server topology and network layout.
stack-managerskill (.claude/skills/stack-manager/) — Lifecycle tooling.