diff --git a/.env.example b/.env.example index 52c087d..1595f35 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,10 @@ # # Quick start (local dev): # cp .env.example .env.local -# docker compose -f docker-compose.dev.yml up -d # Postgres + MinIO +# docker compose -f docker-compose.dev.yml up -d # Postgres + Garage # bun run db:push # or: bun run db:migrate # bun run dev -# MinIO console: http://localhost:9001 (minioadmin / minioadmin) +# Garage S3 API: http://localhost:3900 # # Quick start (Docker app + Postgres): # cp .env.example .env @@ -66,8 +66,7 @@ DB_DISABLE_SSL=true # Dev-only: host ports for `docker compose -f docker-compose.dev.yml`. POSTGRES_PORT=5432 -MINIO_API_PORT=9000 -MINIO_CONSOLE_PORT=9001 +GARAGE_API_PORT=3900 # Optional: if Next dev picks another port, you do not need to change URLs for # sign-in — the auth client uses window.location.origin in the browser. @@ -131,30 +130,28 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Receipt storage — S3-compatible (optional) # ============================================================================= # 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. +# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, 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) +# S3_ENDPOINT — who can reach Garage? +# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900 +# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name) # • Coolify — see docs/COOLIFY.md. Summary: -# - Best: one Compose resource with docker-compose.coolify.yml (app+db+minio). -# - Application + separate MinIO: ENOTFOUND minio → set S3_ENDPOINT to -# SERVICE_URL_MINIO_9000 (public domain) OR http://minio-:9000 -# with Connect to Predefined Network on both resources. Never bare "minio". -# - NEVER use localhost in production — inside the app container that is the app, not MinIO. +# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage). +# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to +# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-:3900 +# with Connect to Predefined Network on both resources. Never bare "garage". +# - NEVER use localhost in production — inside the app container that is the app, not Garage. # -# Local dev with docker-compose.dev.yml MinIO (host `bun dev`): -S3_ENDPOINT=http://localhost:9000 +# Local dev with docker-compose.dev.yml Garage (host `bun dev`): +S3_ENDPOINT=http://localhost:3900 S3_BUCKET=beenvoice-receipts -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin -S3_REGION=us-east-1 -# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for MinIO/HTTPS proxy +S3_ACCESS_KEY=GK3515373e4c851ebaad366558 +S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34 +S3_REGION=garage +# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy # -# docker-compose.yml sets S3_ENDPOINT=http://minio:9000 inside the app container -# automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY. -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=minioadmin +# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container +# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env. # ============================================================================= # SSO — Authentik OIDC (optional) diff --git a/README.md b/README.md index 2a845cb..b62c687 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ To verify migration files match the journal before deploy: `bun run db:verify-jo ### Coolify -For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND minio` with Application + separate MinIO compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource. +For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource. ### 4. Sign-ups @@ -194,9 +194,9 @@ beenvoice-web/ ├── src/lib/ # auth, PDF, email, branding helpers ├── drizzle/ # SQL migrations ├── Dockerfile # Production image (migrate + next start) -├── docker-compose.yml # App + Postgres + MinIO (deploy) -├── docker-compose.coolify.yml # Coolify Compose (app + db + minio) -├── docker-compose.coolify-minio.yml # MinIO-only for Coolify Application pairing +├── docker-compose.yml # App + Postgres + Garage (deploy) +├── docker-compose.coolify.yml # Coolify Compose (app + db + garage) +├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing ├── docker-compose.dev.yml # Postgres only (local dev) └── docs/ # Architecture and UI guides ``` @@ -256,7 +256,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation. | Doc | Contents | |-----|----------| | [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP | -| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and MinIO networking | +| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking | | [docs/README.md](./docs/README.md) | Index of UI and product guides | | [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development | diff --git a/docker-compose.coolify-garage.yml b/docker-compose.coolify-garage.yml new file mode 100644 index 0000000..6d145d9 --- /dev/null +++ b/docker-compose.coolify-garage.yml @@ -0,0 +1,74 @@ +# Garage-only stack for Coolify when beenvoice runs as a separate Application resource. +# +# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml +# +# ── Pair with a beenvoice Application (pick ONE) ─────────────────────────────── +# +# A) Public Garage URL (most reliable — no shared Docker network required) +# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below). +# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com). +# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab. +# 4. beenvoice Application → S3_ENDPOINT= → redeploy beenvoice. +# +# B) Internal Docker DNS (same Coolify destination network) +# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy. +# 2. beenvoice Application → same destination → enable "Connect to Predefined Network". +# 3. beenvoice → S3_ENDPOINT=http://garage-:3900 +# +# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage). +# See docs/COOLIFY.md. +services: + garage: + image: dxflrs/garage:v2.3.0 + environment: + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} + SERVICE_FQDN_GARAGE_3900: + configs: + - source: garage_config + target: /etc/garage.toml + volumes: + - beenvoice_garage_meta:/var/lib/garage/meta + - beenvoice_garage_data:/var/lib/garage/data + command: ["/garage", "server", "--single-node", "--default-bucket"] + expose: + - "3900" + healthcheck: + test: ["CMD", "/garage", "status"] + interval: 5s + timeout: 5s + retries: 15 + start_period: 20s + restart: unless-stopped + +volumes: + beenvoice_garage_meta: + beenvoice_garage_data: + +configs: + garage_config: + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "sqlite" + replication_factor = 1 + + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "garage:3901" + rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage" + index = "index.html" + + [admin] + api_bind_addr = "[::]:3903" + admin_token = "beenvoice_garage_admin_token_change_me_in_production" + metrics_token = "beenvoice_garage_metrics_token_change_me_in_production" diff --git a/docker-compose.coolify-minio.yml b/docker-compose.coolify-minio.yml deleted file mode 100644 index 14c5d95..0000000 --- a/docker-compose.coolify-minio.yml +++ /dev/null @@ -1,63 +0,0 @@ -# MinIO-only stack for Coolify when Beevoice runs as a separate Application resource. -# -# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-minio.yml -# -# ── Fix ENOTFOUND minio on a Beevoice Application (pick ONE) ───────────────── -# -# A) Public MinIO URL (most reliable — no shared Docker network required) -# 1. Redeploy this stack (includes SERVICE_FQDN_MINIO_9000 below). -# 2. MinIO resource → assign a domain for port 9000 (e.g. s3.example.com). -# 3. Open this resource's Environment tab → copy SERVICE_URL_MINIO_9000 -# (e.g. https://s3.example.com). -# 4. Beevoice Application → S3_ENDPOINT= → redeploy Beevoice. -# -# B) Internal Docker DNS (same Coolify destination network) -# 1. MinIO resource → Advanced → enable "Connect to Predefined Network" → redeploy. -# 2. Beevoice Application → same destination → enable "Connect to Predefined Network" -# → redeploy. -# 3. Beevoice → S3_ENDPOINT=http://minio-:9000 -# (UUID is in the MinIO resource URL / COOLIFY_RESOURCE_UUID — NOT bare "minio"). -# -# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+minio). -# See docs/COOLIFY.md. -services: - minio: - image: minio/minio:latest - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} - SERVICE_FQDN_MINIO_9000: - SERVICE_FQDN_MINIO_9001: - volumes: - - beenvoice_minio_data:/data - command: server /data --console-address ":9001" - expose: - - "9000" - - "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.coolify.yml b/docker-compose.coolify.yml index 4da285e..8e85542 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -1,13 +1,13 @@ -# Beevoice on Coolify — single Docker Compose resource (recommended). +# beenvoice on Coolify — single Docker Compose resource (recommended). # # Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml # # 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik). -# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, MINIO_ROOT_* in the resource env (see .env.example). -# 3. Do NOT override S3_ENDPOINT — this stack sets http://minio:9000 on the shared network. +# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example). +# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network. # 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP). # -# Migrating from Application + separate Postgres + MinIO compose: +# Migrating from Application + separate Postgres + Garage compose: # - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here. # - Or keep external Postgres and remove the `db` service + volume from this file. services: @@ -36,20 +36,18 @@ services: AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-} - S3_ENDPOINT: http://minio:9000 + S3_ENDPOINT: http://garage:3900 S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} - S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} - S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} - S3_REGION: ${S3_REGION:-us-east-1} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + S3_REGION: ${S3_REGION:-garage} expose: - "3000" depends_on: db: condition: service_healthy - minio: + garage: condition: service_healthy - minio-init: - condition: service_completed_successfully restart: unless-stopped db: @@ -68,44 +66,58 @@ services: retries: 10 restart: unless-stopped - minio: - image: minio/minio:latest - # Optional: assign domains in Coolify for console / external S3 API access. + garage: + image: dxflrs/garage:v2.3.0 environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} - SERVICE_FQDN_MINIO_9001: + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} + SERVICE_FQDN_GARAGE_3900: + configs: + - source: garage_config + target: /etc/garage.toml volumes: - - beenvoice_minio_data:/data - command: server /data --console-address ":9001" + - beenvoice_garage_meta:/var/lib/garage/meta + - beenvoice_garage_data:/var/lib/garage/data + command: ["/garage", "server", "--single-node", "--default-bucket"] expose: - - "9000" - - "9001" + - "3900" healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: ["CMD", "/garage", "status"] interval: 5s timeout: 5s - retries: 10 + retries: 15 + start_period: 20s 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_pg_data: - beenvoice_minio_data: + beenvoice_garage_meta: + beenvoice_garage_data: + +configs: + garage_config: + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "sqlite" + replication_factor = 1 + + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "garage:3901" + rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage" + index = "index.html" + + [admin] + api_bind_addr = "[::]:3903" + admin_token = "beenvoice_garage_admin_token_change_me_in_production" + metrics_token = "beenvoice_garage_metrics_token_change_me_in_production" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2c55bc1..6f72af6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,43 +17,58 @@ services: - "${POSTGRES_PORT:-5432}:5432" restart: unless-stopped - # S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001. - minio: - image: minio/minio:latest + # S3-compatible receipt storage for host dev (`bun dev`). API :3900. + garage: + image: dxflrs/garage:v2.3.0 environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} + configs: + - source: garage_config + target: /etc/garage.toml volumes: - - beenvoice_dev_minio_data:/data - command: server /data --console-address ":9001" + - beenvoice_dev_garage_meta:/var/lib/garage/meta + - beenvoice_dev_garage_data:/var/lib/garage/data + command: ["/garage", "server", "--single-node", "--default-bucket"] ports: - - "${MINIO_API_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" + - "${GARAGE_API_PORT:-3900}:3900" healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: ["CMD", "/garage", "status"] interval: 5s timeout: 5s - retries: 10 + retries: 15 + start_period: 20s 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_dev_pg_data: - beenvoice_dev_minio_data: + beenvoice_dev_garage_meta: + beenvoice_dev_garage_data: + +configs: + garage_config: + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "sqlite" + replication_factor = 1 + + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "garage:3901" + rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage" + index = "index.html" + + [admin] + api_bind_addr = "[::]:3903" + admin_token = "beenvoice_garage_admin_token_change_me_in_production" + metrics_token = "beenvoice_garage_metrics_token_change_me_in_production" diff --git a/docker-compose.yml b/docker-compose.yml index 06c9b37..874e085 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ -# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml +# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml # # Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred), -# or this file. S3_ENDPOINT=http://minio:9000 works only inside a single stack. -# Application + separate MinIO → docs/COOLIFY.md. +# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack. +# Application + separate Garage → 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: @@ -35,20 +35,18 @@ services: AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-} - S3_ENDPOINT: http://minio:9000 + S3_ENDPOINT: http://garage:3900 S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} - S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} - S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} - S3_REGION: ${S3_REGION:-us-east-1} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + S3_REGION: ${S3_REGION:-garage} ports: - "${WEB_PORT:-${PORT:-3000}}:3000" depends_on: db: condition: service_healthy - minio: + garage: condition: service_healthy - minio-init: - condition: service_completed_successfully restart: unless-stopped db: @@ -67,43 +65,58 @@ services: retries: 10 restart: unless-stopped - # S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose). - minio: - image: minio/minio:latest + # S3-compatible receipt storage (~50–100 MB RAM vs MinIO). API :3900. + garage: + image: dxflrs/garage:v2.3.0 environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} + configs: + - source: garage_config + target: /etc/garage.toml volumes: - - beenvoice_minio_data:/data - command: server /data --console-address ":9001" + - beenvoice_garage_meta:/var/lib/garage/meta + - beenvoice_garage_data:/var/lib/garage/data + command: ["/garage", "server", "--single-node", "--default-bucket"] ports: - - "${MINIO_API_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" + - "${GARAGE_API_PORT:-3900}:3900" healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: ["CMD", "/garage", "status"] interval: 5s timeout: 5s - retries: 10 + retries: 15 + start_period: 20s 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_pg_data: - beenvoice_minio_data: + beenvoice_garage_meta: + beenvoice_garage_data: + +configs: + garage_config: + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "sqlite" + replication_factor = 1 + + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "garage:3901" + rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage" + index = "index.html" + + [admin] + api_bind_addr = "[::]:3903" + admin_token = "beenvoice_garage_admin_token_change_me_in_production" + metrics_token = "beenvoice_garage_metrics_token_change_me_in_production" diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md index 9459c45..b5d9b7b 100644 --- a/docs/COOLIFY.md +++ b/docs/COOLIFY.md @@ -1,100 +1,102 @@ -# Coolify deployment — Beevoice + MinIO +# Coolify deployment — beenvoice + Garage -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. +beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50–100 MB RAM vs MinIO's ~500 MB+). -## Why `getaddrinfo ENOTFOUND minio` happens +## Why `getaddrinfo ENOTFOUND garage` happens Docker DNS resolves service names **only inside the same Docker network**. -| Setup | Does `http://minio:9000` work? | -|-------|-------------------------------| -| Single Compose stack (app + minio together) | Yes — Compose service name `minio` | -| Beevoice **Application** + MinIO **separate Compose** | **No** — each resource has its own network by default | -| Application + MinIO with shared destination network + correct hostname | Yes — hostname is usually **`minio-`**, not bare `minio` | -| Application + MinIO via **public domain** (`SERVICE_URL_MINIO_9000`) | Yes — no Docker DNS needed | +| Setup | Does `http://garage:3900` work? | +|-------|--------------------------------| +| Single Compose stack (app + garage together) | Yes — Compose service name `garage` | +| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default | +| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-`**, not bare `garage` | +| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed | -Setting `S3_ENDPOINT=http://minio:9000` on a standalone Beevoice Application fails because the app container is not on the MinIO stack's network. Node returns `ENOTFOUND minio`. +Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`. -Also avoid `http://localhost:9000` inside the app container — that points at the app itself, not MinIO. +Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage. --- -## Quick fix — keep Beevoice as Application + separate MinIO compose +## Quick fix — keep beenvoice as Application + separate Garage compose Use this if you are **not** migrating to a single Compose stack today. -### Path A — public MinIO URL (recommended, works without shared Docker network) +### Path A — public Garage URL (recommended, works without shared Docker network) -This is the most reliable fix when Beevoice is a Coolify **Application** (Dockerfile) and MinIO is a separate Compose resource. +This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource. -1. **Update the MinIO stack** to the latest `docker-compose.coolify-minio.yml` from this repo (includes `SERVICE_FQDN_MINIO_9000`) and **redeploy** the MinIO resource. -2. In the **MinIO Compose resource** → assign a domain for **port 9000** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy. -3. Open the MinIO resource **Environment** tab and copy **`SERVICE_URL_MINIO_9000`** (e.g. `https://s3.yourdomain.com`). -4. On the **Beevoice Application** → Environment: +1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource. +2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy. +3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`). +4. On the **beenvoice Application** → Environment: ```env S3_ENDPOINT=https://s3.yourdomain.com S3_BUCKET=beenvoice-receipts -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_REGION=us-east-1 +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_REGION=garage ``` -5. **Redeploy Beevoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy). +5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy). -`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for MinIO behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets. +`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets. -### Path B — internal Docker DNS (same destination, no public MinIO domain) +### Path B — internal Docker DNS (same destination, no public Garage domain) -Use when you want MinIO API traffic to stay on the Docker network. +Use when you want S3 API traffic to stay on the Docker network. -1. Put Beevoice Application and MinIO Compose in the **same Coolify project** and **same destination** (server/network). -2. **MinIO Compose resource** → **Advanced** → enable **Connect to Predefined Network** → **redeploy MinIO**. -3. **Beevoice Application** → **Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy Beevoice**. -4. Find the MinIO resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the MinIO container). -5. Set on Beevoice Application: +1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network). +2. **Garage Compose resource** → **Advanced** → enable **Connect to Predefined Network** → **redeploy Garage**. +3. **beenvoice Application** → **Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**. +4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container). +5. Set on beenvoice Application: ```env -S3_ENDPOINT=http://minio-:9000 +S3_ENDPOINT=http://garage-:3900 ``` -Example: resource UUID `k8w2o0g4s0g8` → `S3_ENDPOINT=http://minio-k8w2o0g4s0g8:9000`. +Example: resource UUID `k8w2o0g4s0g8` → `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`. -**Do not use bare `minio`** unless you verified it resolves from inside the Beevoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://minio:9000/minio/health/live` fails, use the `minio-` form or Path A). +**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-` form or Path A). 6. Match credentials and bucket: ```env S3_BUCKET=beenvoice-receipts -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_REGION=us-east-1 +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_REGION=garage ``` --- ## Recommended long-term — one Compose stack -Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + MinIO + minio-init). This is the lowest-friction production layout on Coolify. +Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify. 1. Coolify → **New Resource** → **Docker Compose** 2. Point at this repo; compose file: **`docker-compose.coolify.yml`** -3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `MINIO_ROOT_*`, etc. +3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc. 4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically). -5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://minio:9000` on the shared network. +5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network. 6. Redeploy. -Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/MinIO. +Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage. -### Migrating from Application + external Postgres + MinIO +### Migrating from Application + external Postgres + Garage (or legacy MinIO) | Current | Action | |---------|--------| -| Beevoice Application | Remove after Compose stack is live | +| beenvoice Application | Remove after Compose stack is live | | Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file | -| MinIO compose | Remove after data migrated or re-point receipts (new bucket) | +| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) | | Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env | +**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated. + --- ## Compose file reference @@ -103,33 +105,33 @@ Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; ` |------|---------| | [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource | | [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify | -| [`docker-compose.coolify-minio.yml`](../docker-compose.coolify-minio.yml) | MinIO + bucket init only; pair with Beevoice Application (Path A or B above) | +| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) | Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your 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 (Application + separate MinIO) +## Checklist (Application + separate Garage) -- [ ] MinIO stack redeployed with current `docker-compose.coolify-minio.yml` -- [ ] **Path A:** domain on port 9000 + `S3_ENDPOINT` = `SERVICE_URL_MINIO_9000` - **or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://minio-:9000` -- [ ] `S3_ENDPOINT` is **not** `http://minio:9000`, **not** `localhost` -- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` -- [ ] `S3_BUCKET` exists (`minio-init` creates `beenvoice-receipts` by default) -- [ ] Redeployed Beevoice after env or network changes +- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml` +- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900` + **or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-:3900` +- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost` +- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env +- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start) +- [ ] Redeployed beenvoice after env or network changes -## Verify from the Beevoice container +## Verify from the beenvoice container ```bash -# Shell into Beevoice app container on the Coolify server +# Shell into beenvoice app container on the Coolify server docker exec -it sh -# Path A — public URL (include scheme; path is /minio/health/live on API port) -wget -qO- "https://s3.yourdomain.com/minio/health/live" || curl -sf "https://s3.yourdomain.com/minio/health/live" +# Path A — public URL (403/404 on root is fine — confirms DNS + TLS) +wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com" -# Path B — internal host from S3_ENDPOINT (no scheme/port in HOST) -wget -qO- "http://minio-:9000/minio/health/live" || curl -sf "http://minio-:9000/minio/health/live" +# Path B — internal host from S3_ENDPOINT +wget -qO- "http://garage-:3900" || curl -sf "http://garage-:3900" ``` -If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `minio` in production. +If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production. diff --git a/docs/README.md b/docs/README.md index 0c710cf..490be42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +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`) | +| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) | ## UI & product guides diff --git a/scripts/docker-deploy.sh b/scripts/docker-deploy.sh index 57e9fa3..1bf89bf 100755 --- a/scripts/docker-deploy.sh +++ b/scripts/docker-deploy.sh @@ -3,8 +3,8 @@ set -euo pipefail # Production deploy helper for docker-compose.yml (not docker-compose.dev.yml). # Rebuilds the app image from the current working tree, then starts/restarts services -# (app, db, minio, minio-init). Receipt storage uses in-stack MinIO unless S3_* are -# overridden in .env. MinIO API/console: localhost:${MINIO_API_PORT:-9000} / :9001. +# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are +# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}. # # Plain `docker compose up -d` reuses the local image tag and does NOT pick up # changes from `git pull`. Always pass --build or use this script after pulling. diff --git a/src/lib/object-storage.ts b/src/lib/object-storage.ts index f5383b4..c58f7c4 100644 --- a/src/lib/object-storage.ts +++ b/src/lib/object-storage.ts @@ -22,7 +22,7 @@ type S3Module = typeof import("@aws-sdk/client-s3"); let s3ModulePromise: Promise | null = null; let s3Client: InstanceType | null = null; let s3DnsHintLogged = false; -let s3BareMinioHintLogged = false; +let s3BareGarageHintLogged = false; function shouldForcePathStyle(): boolean { const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase(); @@ -31,19 +31,19 @@ function shouldForcePathStyle(): boolean { return Boolean(process.env.S3_ENDPOINT); } -function logBareMinioEndpointHint(): void { - if (s3BareMinioHintLogged || process.env.NODE_ENV !== "production") return; +function logBareGarageEndpointHint(): void { + if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return; const endpoint = process.env.S3_ENDPOINT; if (!endpoint) return; try { const { hostname } = new URL(endpoint); - if (hostname !== "minio") return; - s3BareMinioHintLogged = true; + if (hostname !== "garage") return; + s3BareGarageHintLogged = true; console.warn( - "[object-storage] S3_ENDPOINT hostname is bare 'minio'. " + + "[object-storage] S3_ENDPOINT hostname is bare 'garage'. " + "That only resolves inside a single Docker Compose stack. " + - "Coolify Application + separate MinIO compose: set S3_ENDPOINT to " + - "SERVICE_URL_MINIO_9000 (public domain) or http://minio-:9000. " + + "Coolify Application + separate Garage compose: set S3_ENDPOINT to " + + "SERVICE_URL_GARAGE_3900 (public domain) or http://garage-:3900. " + "See docs/COOLIFY.md.", ); } catch { @@ -59,7 +59,7 @@ function logS3DnsHint(error: unknown): void { 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.", + "Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.", ); } @@ -76,7 +76,7 @@ async function getS3() { s3ModulePromise ??= import("@aws-sdk/client-s3"); const mod = await s3ModulePromise; if (!s3Client) { - logBareMinioEndpointHint(); + logBareGarageEndpointHint(); s3Client = new mod.S3Client({ region: process.env.S3_REGION ?? "us-east-1", endpoint: process.env.S3_ENDPOINT, @@ -84,7 +84,7 @@ async function getS3() { accessKeyId: process.env.S3_ACCESS_KEY!, secretAccessKey: process.env.S3_SECRET_KEY!, }, - // Required for MinIO and most S3-compatible endpoints (including HTTPS proxies). + // Required for Garage and most S3-compatible endpoints (including HTTPS proxies). forcePathStyle: shouldForcePathStyle(), }); }