diff --git a/.env.example b/.env.example index 36d3873..43b64d3 100644 --- a/.env.example +++ b/.env.example @@ -130,8 +130,23 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # ============================================================================= # Receipt storage — S3-compatible (optional) # ============================================================================= -# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly). -# Works with AWS S3, MinIO, Cloudflare R2, etc. +# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in +# .data/receipts/ (dev-friendly). Works with AWS S3, MinIO, Cloudflare R2, etc. +# +# S3_ENDPOINT — who can reach MinIO? +# • Host dev (bun dev + docker-compose.dev.yml MinIO on the host): localhost:9000 +# • App in Docker (docker-compose.yml): http://minio:9000 (Compose service name) +# • Coolify — see docs/COOLIFY.md for full steps. Summary: +# - Best: one Compose resource with docker-compose.yml (app+db+minio); do not override S3_ENDPOINT. +# - App + separate MinIO stack: ENOTFOUND minio means the app is not on MinIO's Docker network. +# Fix: Beevoice Application → enable "Connect to Predefined Network" (same destination as MinIO), +# set S3_ENDPOINT=http://:9000 (often NOT bare "minio"). +# - NEVER use localhost in production — inside the app container that is the app, not MinIO. +# Troubleshooting getaddrinfo ENOTFOUND minio: +# 1) Confirm Beevoice and MinIO are same Coolify project/destination +# 2) Enable Connect to Predefined Network on Beevoice; redeploy +# 3) Copy hostname from MinIO resource internal URL → S3_ENDPOINT (http://HOST:9000) +# 4) Or deploy docker-compose.yml as a single stack instead # # Local dev with docker-compose.dev.yml MinIO (host `bun dev`): S3_ENDPOINT=http://localhost:9000 diff --git a/Dockerfile b/Dockerfile index 4c29e43..b2a98e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000 ARG BETTER_AUTH_URL=http://localhost:3000 # Low-memory Docker build profile: -# - disable React Compiler (saves compile RAM; prod image still runs fine without it) # - skip tsc inside `next build` (run `bun run check` in CI instead) ENV DOCKER_BUILD=1 \ - DISABLE_REACT_COMPILER=1 \ NODE_ENV=production \ SKIP_ENV_VALIDATION=1 \ NEXT_TELEMETRY_DISABLED=1 \ diff --git a/docker-compose.coolify-minio.yml b/docker-compose.coolify-minio.yml new file mode 100644 index 0000000..4bed91c --- /dev/null +++ b/docker-compose.coolify-minio.yml @@ -0,0 +1,48 @@ +# MinIO-only stack for Coolify when Beevoice runs as a separate Application resource. +# +# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml +# +# Beevoice (Application) cannot use S3_ENDPOINT=http://minio:9000 unless it shares +# this stack's Docker network AND that hostname resolves (usually it does not across +# separate Coolify resources). See docs/COOLIFY.md — copy the internal hostname from +# the MinIO resource UI into Beevoice's S3_ENDPOINT and enable "Connect to Predefined +# Network" on the Beevoice app. +# +# Recommended alternative: deploy full docker-compose.yml as one Compose resource (app +# + db + minio) so S3_ENDPOINT=http://minio:9000 works without extra networking. +services: + minio: + image: minio/minio:latest + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - beenvoice_minio_data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 + "$${MINIO_ROOT_USER:-minioadmin}" + "$${MINIO_ROOT_PASSWORD:-minioadmin}" && + mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing + restart: "no" + +volumes: + beenvoice_minio_data: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2f8a6cf..2c55bc1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,7 +19,7 @@ services: # S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001. minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z + image: minio/minio:latest environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -37,7 +37,7 @@ services: restart: unless-stopped minio-init: - image: minio/mc:RELEASE.2025-04-22T16-22-07Z + image: minio/mc:latest depends_on: minio: condition: service_healthy @@ -45,11 +45,13 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && - mc mb local/$$S3_BUCKET --ignore-existing - " + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 + "$${MINIO_ROOT_USER:-minioadmin}" + "$${MINIO_ROOT_PASSWORD:-minioadmin}" && + mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing restart: "no" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 86c6b24..e0483c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ # Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml # +# Coolify: deploy this file as ONE Docker Compose resource so S3_ENDPOINT=http://minio:9000 works. +# Separate Application + MinIO stacks need shared networking — see docs/COOLIFY.md. +# # After git pull, rebuild before starting — a plain `docker compose up -d` reuses # the existing local image and will NOT include new code. Use: # ./scripts/docker-deploy.sh @@ -65,7 +68,7 @@ services: # S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose). minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z + image: minio/minio:latest environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -83,7 +86,7 @@ services: restart: unless-stopped minio-init: - image: minio/mc:RELEASE.2025-04-22T16-22-07Z + image: minio/mc:latest depends_on: minio: condition: service_healthy @@ -91,11 +94,13 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && - mc mb local/$$S3_BUCKET --ignore-existing - " + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 + "$${MINIO_ROOT_USER:-minioadmin}" + "$${MINIO_ROOT_PASSWORD:-minioadmin}" && + mc mb "local/$${S3_BUCKET:-beenvoice-receipts}" --ignore-existing restart: "no" volumes: diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md new file mode 100644 index 0000000..064b9ea --- /dev/null +++ b/docs/COOLIFY.md @@ -0,0 +1,151 @@ +# Coolify deployment — Beevoice + MinIO + +Beevoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. MinIO is the usual choice on self-hosted Coolify. + +## Why `getaddrinfo ENOTFOUND minio` happens + +Docker DNS resolves service names **only inside the same Docker network**. + +| Setup | Does `http://minio:9000` work? | +|-------|-------------------------------| +| Single `docker-compose.yml` stack (app + minio together) | Yes — Compose service name `minio` | +| Beevoice **Application** + MinIO **separate Compose** resource | **No** — each Coolify resource gets its own network by default | +| Both resources share a Coolify **destination** network + correct hostname | Yes — but hostname is often **not** bare `minio` | + +Setting `S3_ENDPOINT=http://minio:9000` on a standalone Beevoice Application fails because the app container is not on the MinIO stack's internal network. Node's DNS lookup returns `ENOTFOUND minio`. + +Also avoid `http://localhost:9000` inside the app container — that points at the app itself, not MinIO. + +--- + +## Recommended: Option C — one Compose stack (simplest) + +Deploy the repo's full [`docker-compose.yml`](../docker-compose.yml) as **one** Coolify **Docker Compose** resource (app + Postgres + MinIO + minio-init). + +1. Coolify → **New Resource** → **Docker Compose** +2. Point at this repo; compose file: `docker-compose.yml` +3. Set env vars from [`.env.example`](../.env.example) (`AUTH_SECRET`, `BETTER_AUTH_URL`, `NEXT_PUBLIC_APP_URL`, etc.) +4. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://minio:9000` for the app service automatically +5. Redeploy + +All services share one Compose network; `minio` resolves correctly. + +--- + +## Option A — separate resources, shared Coolify network + +Use when Beevoice stays a standalone **Application** (Dockerfile) and MinIO is a separate Compose resource. + +### 1. Same Coolify project and destination + +Put both resources in the **same Coolify project** and deploy them to the **same destination** (same Docker network / server). + +### 2. Connect the Beevoice app to that network + +On the **Beevoice Application** resource: + +1. Open **Advanced** (or network settings) +2. Enable **Connect to Predefined Network** +3. Select the **same destination/network** as the MinIO stack +4. **Redeploy** the app (required after toggling network) + +The MinIO stack does **not** need this option — only the service that **initiates** connections (Beevoice) needs it. + +### 3. Set `S3_ENDPOINT` to the real internal hostname + +Bare `minio` usually still fails across separate Coolify resources. Use the hostname Coolify assigns on the shared network: + +1. Open the **MinIO Compose** resource in Coolify +2. Find the **internal URL** / connection info (eye icon next to internal connection string) +3. Copy the **hostname** from that URL (not `localhost`, not bare `minio` unless you verified it resolves) + +Typical patterns: + +| What you see | Use as `S3_ENDPOINT` | +|--------------|----------------------| +| Internal URL host `minio-abc123def456` | `http://minio-abc123def456:9000` | +| Container name `x8k2j4...` (random id) | `http://x8k2j4...:9000` | +| Same compose stack only | `http://minio:9000` | + +On the Coolify server you can confirm: + +```bash +# List MinIO containers +docker ps --filter name=minio + +# See DNS aliases on the shared network (replace CONTAINER and NETWORK) +docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jq +``` + +Set on the **Beevoice Application** env: + +```env +S3_ENDPOINT=http://:9000 +S3_BUCKET=beenvoice-receipts +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_REGION=us-east-1 +``` + +Redeploy Beevoice after changing env. + +### 4. Enable on MinIO stack too (only if A still fails) + +If the app still cannot resolve the hostname, enable **Connect to Predefined Network** on the **MinIO Compose** resource as well (same destination), redeploy MinIO, then re-check the internal URL — Coolify may expose a `minio-` alias on the shared network. + +--- + +## Option B — internal URL from Coolify UI (quick fix) + +Same as Option A step 3, without re-architecting: + +1. MinIO resource → copy **internal** hostname (from internal URL field) +2. Beevoice Application → `S3_ENDPOINT=http://:9000` +3. Enable **Connect to Predefined Network** on Beevoice if not already +4. Redeploy Beevoice + +If DNS still fails, the app is not on the network where that hostname is registered — go back to Option A or use Option C. + +--- + +## Option D — public / external MinIO URL (fallback) + +If internal Docker DNS cannot be made to work: + +```env +S3_ENDPOINT=https://minio.yourdomain.com +``` + +Expose MinIO API (port 9000) via Coolify proxy or a public domain. Less ideal (traffic leaves the Docker network, TLS/path-style config may need tuning) but avoids internal DNS entirely. + +--- + +## Separate MinIO-only Compose file + +[`docker-compose.coolify-minio.yml`](../docker-compose.coolify-minio.yml) deploys only MinIO + bucket init for a dedicated Coolify Compose resource. Pair it with a Beevoice Application using Option A or B. + +Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your Coolify server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose. + +--- + +## Checklist + +- [ ] Beevoice and MinIO in the same Coolify **project** +- [ ] Same **destination** / server +- [ ] Beevoice Application: **Connect to Predefined Network** enabled (when MinIO is a separate resource) +- [ ] `S3_ENDPOINT` uses internal hostname from Coolify UI — not `localhost`, not unverified `minio` +- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match MinIO `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` +- [ ] `S3_BUCKET` exists ( `minio-init` in compose creates `beenvoice-receipts` by default) +- [ ] Redeployed after env or network changes + +## Verify from the Beevoice container + +```bash +# Shell into Beevoice app container on Coolify server +docker exec -it sh + +# Replace HOST with your S3_ENDPOINT hostname (no scheme/port) +wget -qO- "http://HOST:9000/minio/health/live" || curl -sf "http://HOST:9000/minio/health/live" +``` + +If this fails with "bad address" or timeout, fix networking before debugging app code. diff --git a/docs/README.md b/docs/README.md index 13e0ce7..0c710cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ |----------|-------------| | [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract | | [../README.md](../README.md) | Install, scripts, deployment | +| [COOLIFY.md](./COOLIFY.md) | Coolify + MinIO networking (`ENOTFOUND minio`) | ## UI & product guides diff --git a/src/lib/object-storage.ts b/src/lib/object-storage.ts index ed62744..37d2b21 100644 --- a/src/lib/object-storage.ts +++ b/src/lib/object-storage.ts @@ -21,6 +21,28 @@ type S3Module = typeof import("@aws-sdk/client-s3"); let s3ModulePromise: Promise | null = null; let s3Client: InstanceType | null = null; +let s3DnsHintLogged = false; + +function logS3DnsHint(error: unknown): void { + if (s3DnsHintLogged) return; + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return; + s3DnsHintLogged = true; + const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)"; + console.error( + `[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` + + "Separate Coolify stacks cannot resolve bare 'minio' — use the internal hostname from the MinIO resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.", + ); +} + +async function withS3Diagnostics(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + logS3DnsHint(error); + throw error; + } +} async function getS3() { if (!s3ModulePromise) { @@ -53,13 +75,15 @@ export async function putObject( ): Promise { if (isS3Configured()) { const { client, PutObjectCommand } = await getS3(); - await client.send( - new PutObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - Body: body, - ContentType: contentType, - }), + await withS3Diagnostics(() => + client.send( + new PutObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + Body: body, + ContentType: contentType, + }), + ), ); return; } @@ -72,11 +96,13 @@ export async function putObject( export async function getObject(key: string): Promise { if (isS3Configured()) { const { client, GetObjectCommand } = await getS3(); - const response = await client.send( - new GetObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - }), + const response = await withS3Diagnostics(() => + client.send( + new GetObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + }), + ), ); const bytes = await response.Body?.transformToByteArray(); if (!bytes) { @@ -91,11 +117,13 @@ export async function getObject(key: string): Promise { export async function deleteObject(key: string): Promise { if (isS3Configured()) { const { client, DeleteObjectCommand } = await getS3(); - await client.send( - new DeleteObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - }), + await withS3Diagnostics(() => + client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + }), + ), ); return; }