infra: migrate deployment to Coolify with Garage S3
Some checks failed
CI / ci (push) Failing after 19s
CI / deploy (push) Has been skipped
CI / e2e (push) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 15:28:43 +02:00
parent 41e58d0153
commit d519a83cc4
11 changed files with 107 additions and 272 deletions

View File

@@ -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)

View File

@@ -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

1
.gitignore vendored
View File

@@ -154,6 +154,7 @@ web_modules/
# dotenv environment variable files
.env
.env.coolify-*
.env.development.local
.env.test.local
.env.production.local

108
README.md
View File

@@ -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
- `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

View File

@@ -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:

View File

@@ -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:

20
docker/garage.toml Normal file
View File

@@ -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"

View File

@@ -1,2 +0,0 @@
-- Creates a separate database for Logto on the shared Postgres instance
CREATE DATABASE logto;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);