# Deploy: Portainer (git stack) + external Traefik LXC ## Portainer 1. Stacks → Add stack → **Repository** 2. Repository URL: this repo. Reference: branch to track (e.g. `main`) 3. Compose path: `compose.prod.yml` 4. Environment variables (Portainer stack env, not committed): ``` POSTGRES_DB=epicure POSTGRES_USER=epicure POSTGRES_PASSWORD= REDIS_PASSWORD= MINIO_ROOT_USER= MINIO_ROOT_PASSWORD= BETTER_AUTH_SECRET= BETTER_AUTH_URL=https://HOST_DOMAIN ENCRYPTION_SECRET= NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= WEB_PORT=3000 # optional GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= NEXT_PUBLIC_GITHUB_ENABLED= DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= NEXT_PUBLIC_DISCORD_ENABLED= AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= AUTHENTIK_BASE_URL= NEXT_PUBLIC_AUTHENTIK_ENABLED= SMTP_HOST= SMTP_PORT=587 SMTP_SECURE=false SMTP_USER= SMTP_PASS= SMTP_FROM=Epicure STRIPE_WEBHOOK_SECRET= OPENROUTER_API_KEY= OPENROUTER_DEFAULT_MODEL= OPENAI_API_KEY= ANTHROPIC_API_KEY= OLLAMA_BASE_URL= ``` Every var listed here must exist in `compose.prod.yml`'s `web.environment` block to actually reach the container — Portainer stack env alone isn't enough, it only fills in `${...}` refs the compose file declares. `NEXT_PUBLIC_*` vars (`NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `NEXT_PUBLIC_GITHUB_ENABLED`, `NEXT_PUBLIC_DISCORD_ENABLED`, `NEXT_PUBLIC_AUTHENTIK_ENABLED`) are baked into the client JS bundle at **build time**, not read at container startup — they're wired as Docker `build.args` in compose.prod.yml, not just `environment`. Changing one of these requires Portainer to actually rebuild the image (redeploy with "re-pull image and rebuild"), a plain container restart won't pick up the new value. 5. Deploy the stack. Portainer builds `web` from the repo's root `Dockerfile` (see `build:` in compose.prod.yml) — no separate image push needed. 6. Enable GitOps updates (webhook or polling) on the stack if you want redeploy-on-push. ## Migrations + seed Handled automatically. The `migrate` service builds from the `migrator` Dockerfile target, runs `drizzle-kit migrate` then the tier-definitions seed, and exits; `web` waits for it to complete successfully before starting (`depends_on: migrate: condition: service_completed_successfully`). Both are idempotent — safe to re-run on every stack redeploy, not just the first one. ## Traefik (separate LXC, file provider) 1. Copy `traefik/epicure.yml` into the traefik LXC's dynamic config directory. 2. Replace `HOST_DOMAIN` with the public hostname and `PORTAINER_LXC_IP` with the portainer LXC's network IP (must match `WEB_PORT` published in compose.prod.yml). 3. Confirm `certResolver` name matches what's set in traefik's static config. 4. Traefik picks it up automatically (file provider watches for changes) — no restart needed. ## Notes - `web` connects to `postgres`/`redis`/`minio` over the compose-internal network; only `web`'s port is published to the LXC host for traefik to reach. - `apps/web/next.config.ts` has `output: "standalone"` — required for the Dockerfile's slim runtime stage.