From d519a83cc431f271fbd2497d0e8b5b9dab36aece Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 7 Apr 2026 15:28:43 +0200 Subject: [PATCH] infra: migrate deployment to Coolify with Garage S3 - Remove docker-compose files (Coolify manages services individually) - Replace MinIO with Garage (S3-compatible, actively maintained) - Add CI deploy job: build+push :develop image on every green Develop push - Add Coolify webhook trigger for automatic redeployment - Update README, .env.example, and storage references - Rename migrate script to provider-agnostic name Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 33 +++--- .gitea/workflows/ci.yml | 25 ++++ .gitignore | 1 + README.md | 108 +++++------------- docker-compose.dev.yml | 68 ----------- docker-compose.yml | 90 --------------- docker/garage.toml | 20 ++++ docker/init-logto-db.sql | 2 - ...es-to-minio.ts => migrate-images-to-s3.ts} | 6 +- src/server/services/storage.service.ts | 6 +- tests/services/storage.service.test.ts | 20 ++-- 11 files changed, 107 insertions(+), 272 deletions(-) delete mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.yml create mode 100644 docker/garage.toml delete mode 100644 docker/init-logto-db.sql rename scripts/{migrate-images-to-minio.ts => migrate-images-to-s3.ts} (91%) diff --git a/.env.example b/.env.example index 5b7e552..7f5d4e5 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,22 @@ # PostgreSQL -POSTGRES_PASSWORD=changeme +DATABASE_URL=postgresql://gearbox:changeme@localhost:5432/gearbox -# Logto OIDC (get from Logto Admin Console at http://localhost:3002) +# S3-compatible Object Storage (Garage, R2, AWS S3) +S3_ENDPOINT=http://localhost:3900 +S3_ACCESS_KEY=your-access-key +S3_SECRET_KEY=your-secret-key +S3_BUCKET=gearbox-images +S3_REGION=garage +# S3_PRESIGN_EXPIRY=3600 # Presigned URL expiry in seconds (default: 1 hour) + +# Logto OIDC LOGTO_ENDPOINT=http://localhost:3001 -LOGTO_ADMIN_ENDPOINT=http://localhost:3002 -LOGTO_CLIENT_ID=your-app-client-id -LOGTO_CLIENT_SECRET=your-app-client-secret -OIDC_AUTH_SECRET=generate-a-random-32-char-string-here - -# Derived (set in docker-compose.yml, not needed here): -# OIDC_ISSUER=${LOGTO_ENDPOINT}/oidc +OIDC_ISSUER=http://localhost:3001/oidc +OIDC_CLIENT_ID=your-app-client-id +OIDC_CLIENT_SECRET=your-app-client-secret +OIDC_AUTH_SECRET=generate-a-random-32-char-string +OIDC_SCOPES=openid profile email +OIDC_REDIRECT_URI=http://localhost:5173/callback # GearBox GEARBOX_URL=http://localhost:3000 - -# S3-compatible Object Storage (MinIO) -S3_ENDPOINT=http://localhost:9000 -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin -S3_BUCKET=gearbox-images -S3_REGION=us-east-1 -# S3_PRESIGN_EXPIRY=3600 # Presigned URL expiry in seconds (default: 1 hour) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e162d1a..e68da2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,6 +29,31 @@ jobs: - name: Build run: bun run build + deploy: + needs: ci + if: gitea.ref == 'refs/heads/Develop' && gitea.event_name == 'push' + runs-on: dind + steps: + - name: Clone repository + run: | + apk add --no-cache git curl docker-cli + git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo + cd repo + git checkout Develop + + - name: Build and push Docker image + working-directory: repo + run: | + REGISTRY="gitea.jeanlucmakiola.de" + IMAGE="${REGISTRY}/${{ gitea.repository_owner }}/gearbox" + docker build -t "${IMAGE}:develop" . + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.repository_owner }}" --password-stdin + docker push "${IMAGE}:develop" + + - name: Trigger Coolify deploy + run: | + curl -s -X GET "${{ secrets.COOLIFY_WEBHOOK }}" + e2e: if: false # E2E tests need rewrite: auth moved from local login to OIDC (Logto). Tests still expect username/password flow. needs: ci diff --git a/.gitignore b/.gitignore index 53675b6..894db26 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ web_modules/ # dotenv environment variable files .env +.env.coolify-* .env.development.local .env.test.local .env.production.local diff --git a/README.md b/README.md index 6352ee2..9609c72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GearBox -A single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads. +A web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads. ## Features @@ -10,113 +10,65 @@ A single-user web app for managing gear collections (bikepacking, sim racing, et - Research threads for comparing candidates before buying - Image uploads for items and candidates -## Quick Start (Docker) +## Deployment -### Docker Compose (recommended) +GearBox is deployed via [Coolify](https://coolify.io/) as a Docker image with separate services for dependencies. -Create a `docker-compose.yml`: +**Required services:** +- **PostgreSQL 16** — primary database +- **Garage** (or any S3-compatible storage) — image uploads +- **Logto** — OIDC authentication -```yaml -services: - gearbox: - image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest - container_name: gearbox - ports: - - "3000:3000" - environment: - - NODE_ENV=production - - DATABASE_PATH=./data/gearbox.db - volumes: - - gearbox-data:/app/data - - gearbox-uploads:/app/uploads - healthcheck: - test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"] - interval: 30s - timeout: 5s - start_period: 10s - retries: 3 - restart: unless-stopped +**GearBox image:** `gitea.jeanlucmakiola.de/makiolaj/gearbox:latest` -volumes: - gearbox-data: - gearbox-uploads: -``` - -Then run: - -```bash -docker compose up -d -``` - -GearBox will be available at `http://localhost:3000`. - -### Docker - -```bash -docker run -d \ - --name gearbox \ - -p 3000:3000 \ - -e NODE_ENV=production \ - -e DATABASE_PATH=./data/gearbox.db \ - -v gearbox-data:/app/data \ - -v gearbox-uploads:/app/uploads \ - --restart unless-stopped \ - gitea.jeanlucmakiola.de/makiolaj/gearbox:latest -``` - -## Data - -All data is stored in two Docker volumes: - -- **gearbox-data** -- SQLite database -- **gearbox-uploads** -- uploaded images - -Back up these volumes to preserve your data. +See `.env.example` for required environment variables. ## Updating -```bash -docker compose pull -docker compose up -d -``` +CI pushes a new Docker image on every release. Coolify auto-deploys when the image tag updates. -Database migrations run automatically on startup. +Database migrations run automatically on startup via `entrypoint.sh`. ## Tech Stack - **Runtime & Package Manager:** [Bun](https://bun.sh) - **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand -- **Backend:** Hono, Drizzle ORM, SQLite (`bun:sqlite`) +- **Backend:** Hono, Drizzle ORM, PostgreSQL +- **Storage:** S3-compatible (Garage, Cloudflare R2, AWS S3) +- **Auth:** OIDC via Logto -## Local Development Setup +## Local Development ### Prerequisites -You must have [Bun](https://bun.sh/) installed on your machine. Docker is not required for local development. +- [Bun](https://bun.sh/) installed +- PostgreSQL, Logto, and Garage running (via Coolify test instance or locally) -### Installation +### Setup -1. Install all dependencies: +1. Install dependencies: ```bash bun install ``` -2. Initialize the local SQLite database (`gearbox.db`): +2. Copy and configure environment: ```bash - bun run db:push + cp .env.example .env + # Edit .env with your service URLs and credentials ``` 3. Start the development servers: ```bash bun run dev ``` - This single command will start both the Vite frontend server (port `5173`) and the Hono backend server (port `3000`) concurrently. + Starts both the Vite frontend (port `5173`) and Hono backend (port `3000`). -Open [http://localhost:5173](http://localhost:5173) in your browser to view the app. +Open [http://localhost:5173](http://localhost:5173) in your browser. -## Additional Commands +## Commands -- `bun run build` — Build the production assets into `dist/client/` -- `bun test` — Run the test suite -- `bun run lint` — Check formatting and lint rules using Biome -- `bun run db:generate` — Generate Drizzle migrations after making schema changes \ No newline at end of file +- `bun run dev` — Start dev servers (frontend + backend) +- `bun run build` — Build production assets +- `bun test` — Run tests +- `bun run lint` — Lint with Biome +- `bun run db:generate` — Generate Drizzle migrations after schema changes diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index dc00d8d..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,68 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: gearbox - POSTGRES_PASSWORD: gearbox - POSTGRES_DB: gearbox - ports: - - "5432:5432" - volumes: - - pgdata-dev:/var/lib/postgresql/data - - ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U gearbox"] - interval: 5s - timeout: 3s - retries: 5 - - logto: - image: svhd/logto:latest - depends_on: - postgres: - condition: service_healthy - entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] - ports: - - "3001:3001" - - "3002:3002" - environment: - TRUST_PROXY_HEADER: "1" - DB_URL: postgres://gearbox:gearbox@postgres:5432/logto - ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001} - ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002} - - # MinIO S3-compatible object storage for image uploads. - # Note: MinIO GitHub repo archived Feb 2026. The S3 API abstraction in - # storage.service.ts makes the provider swappable (SeaweedFS, Garage, AWS S3). - minio: - image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio-data-dev:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 3s - retries: 5 - - minio-init: - image: quay.io/minio/mc:latest - depends_on: - minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set myminio http://minio:9000 minioadmin minioadmin; - mc mb --ignore-existing myminio/gearbox-images; - exit 0; - " - -volumes: - pgdata-dev: - minio-data-dev: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8d67a56..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,90 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: gearbox - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: gearbox - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U gearbox"] - interval: 10s - timeout: 5s - retries: 5 - - logto: - image: svhd/logto:latest - depends_on: - postgres: - condition: service_healthy - entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] - ports: - - "3001:3001" - - "3002:3002" - environment: - TRUST_PROXY_HEADER: "1" - DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto - ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001} - ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002} - - # MinIO S3-compatible object storage for image uploads. - # Note: MinIO GitHub repo archived Feb 2026. The S3 API abstraction in - # storage.service.ts makes the provider swappable (SeaweedFS, Garage, AWS S3). - minio: - image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: ${S3_ACCESS_KEY} - MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} - ports: - - "9000:9000" - volumes: - - minio-data:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 3s - retries: 5 - - minio-init: - image: quay.io/minio/mc:latest - depends_on: - minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY:-minioadmin} ${S3_SECRET_KEY:-minioadmin}; - mc mb --ignore-existing myminio/gearbox-images; - exit 0; - " - - app: - image: gearbox:latest - environment: - DATABASE_URL: postgresql://gearbox:${POSTGRES_PASSWORD}@postgres:5432/gearbox - GEARBOX_URL: ${GEARBOX_URL} - OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://localhost:3001}/oidc - OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID} - OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET} - OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET} - S3_ENDPOINT: http://minio:9000 - S3_ACCESS_KEY: ${S3_ACCESS_KEY} - S3_SECRET_KEY: ${S3_SECRET_KEY} - S3_BUCKET: gearbox-images - ports: - - "3000:3000" - depends_on: - postgres: - condition: service_healthy - logto: - condition: service_started - minio: - condition: service_healthy - -volumes: - pgdata: - minio-data: diff --git a/docker/garage.toml b/docker/garage.toml new file mode 100644 index 0000000..ed7b7d3 --- /dev/null +++ b/docker/garage.toml @@ -0,0 +1,20 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" + +replication_factor = 1 + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[s3_web] +bind_addr = "[::]:3902" +root_domain = ".web.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" + +[rpc] +bind_addr = "[::]:3901" diff --git a/docker/init-logto-db.sql b/docker/init-logto-db.sql deleted file mode 100644 index aac4015..0000000 --- a/docker/init-logto-db.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Creates a separate database for Logto on the shared Postgres instance -CREATE DATABASE logto; diff --git a/scripts/migrate-images-to-minio.ts b/scripts/migrate-images-to-s3.ts similarity index 91% rename from scripts/migrate-images-to-minio.ts rename to scripts/migrate-images-to-s3.ts index 3061c23..2d230d6 100644 --- a/scripts/migrate-images-to-minio.ts +++ b/scripts/migrate-images-to-s3.ts @@ -1,5 +1,5 @@ /** - * One-time migration script: uploads/ -> MinIO (S3-compatible object storage) + * One-time migration script: uploads/ -> S3-compatible object storage * * Reads all image files from the local uploads/ directory and uploads each * to the S3 bucket via the storage service. Preserves original filenames @@ -7,10 +7,10 @@ * * Prerequisites: * - S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY env vars must be set - * - The S3 bucket must exist (created by docker-compose or manually) + * - The S3 bucket must exist * * Usage: - * bun run scripts/migrate-images-to-minio.ts + * bun run scripts/migrate-images-to-s3.ts */ import { readdir } from "node:fs/promises"; diff --git a/src/server/services/storage.service.ts b/src/server/services/storage.service.ts index c704694..d53a7fe 100644 --- a/src/server/services/storage.service.ts +++ b/src/server/services/storage.service.ts @@ -6,9 +6,7 @@ import { } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction -// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.) -// without code changes. +// S3 API abstraction — provider-agnostic (Garage, Cloudflare R2, AWS S3). const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, @@ -17,7 +15,7 @@ const s3 = new S3Client({ accessKeyId: process.env.S3_ACCESS_KEY!, secretAccessKey: process.env.S3_SECRET_KEY!, }, - forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services + forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services }); const bucket = process.env.S3_BUCKET ?? "gearbox-images"; diff --git a/tests/services/storage.service.test.ts b/tests/services/storage.service.test.ts index d7ef10a..0a1b0eb 100644 --- a/tests/services/storage.service.test.ts +++ b/tests/services/storage.service.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; // Mock the S3 client send method const mockSend = mock(() => Promise.resolve({})); const mockGetSignedUrl = mock(() => - Promise.resolve("https://minio:9000/gearbox-images/test.jpg?signed=1"), + Promise.resolve("https://s3.example.com/gearbox-images/test.jpg?signed=1"), ); // Mock modules before importing the service @@ -36,11 +36,11 @@ mock.module("@aws-sdk/s3-request-presigner", () => ({ })); // Set env vars before importing the service -process.env.S3_ENDPOINT = "http://localhost:9000"; -process.env.S3_ACCESS_KEY = "minioadmin"; -process.env.S3_SECRET_KEY = "minioadmin"; +process.env.S3_ENDPOINT = "http://localhost:3900"; +process.env.S3_ACCESS_KEY = "test-access-key"; +process.env.S3_SECRET_KEY = "test-secret-key"; process.env.S3_BUCKET = "gearbox-images"; -process.env.S3_REGION = "us-east-1"; +process.env.S3_REGION = "garage"; // Import after mocking const { uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls } = @@ -51,7 +51,7 @@ describe("storage.service", () => { mockSend.mockClear(); mockGetSignedUrl.mockClear(); mockGetSignedUrl.mockResolvedValue( - "https://minio:9000/gearbox-images/test.jpg?signed=1", + "https://s3.example.com/gearbox-images/test.jpg?signed=1", ); }); @@ -100,7 +100,7 @@ describe("storage.service", () => { const url = await getImageUrl("test-image.jpg"); expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); - expect(url).toBe("https://minio:9000/gearbox-images/test.jpg?signed=1"); + expect(url).toBe("https://s3.example.com/gearbox-images/test.jpg?signed=1"); }); }); @@ -124,7 +124,7 @@ describe("storage.service", () => { const result = await withImageUrl(record); expect(result.imageUrl).toBe( - "https://minio:9000/gearbox-images/test.jpg?signed=1", + "https://s3.example.com/gearbox-images/test.jpg?signed=1", ); expect(result.id).toBe(1); expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); @@ -142,11 +142,11 @@ describe("storage.service", () => { expect(results).toHaveLength(3); expect(results[0].imageUrl).toBe( - "https://minio:9000/gearbox-images/test.jpg?signed=1", + "https://s3.example.com/gearbox-images/test.jpg?signed=1", ); expect(results[1].imageUrl).toBeNull(); expect(results[2].imageUrl).toBe( - "https://minio:9000/gearbox-images/test.jpg?signed=1", + "https://s3.example.com/gearbox-images/test.jpg?signed=1", ); // Called twice: for records[0] and records[2] expect(mockGetSignedUrl).toHaveBeenCalledTimes(2);