feat(docker): add Docker support with configuration files and entrypoint scripts

This commit is contained in:
Yusuf İpek
2026-04-09 15:54:03 +03:00
parent b1b1715578
commit e97d60cf36
13 changed files with 658 additions and 31 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitignore
.next
node_modules
.env
.env.docker
.env.local
.env.production
.env.development
coverage
dist
testsprite_tests
tsconfig.tsbuildinfo
README.md
PROGRESS.md
OPTIMIZATIONS.md
CLAUDE.md
AGENTS.md
+55
View File
@@ -0,0 +1,55 @@
# Copy to .env.docker before starting the Docker stack.
# Application URLs
NEXTAUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
AUTH_TRUST_HOST="true"
# Generate a strong random secret before first boot.
NEXTAUTH_SECRET="replace-with-openssl-rand-base64-32"
# Docker Compose defaults
POSTGRES_DB="openframe"
POSTGRES_USER="replace-with-postgres-user"
POSTGRES_PASSWORD="replace-with-strong-postgres-password"
DATABASE_URL="postgresql://replace-with-postgres-user:replace-with-strong-postgres-password@postgres:5432/openframe?schema=public"
NODE_ENV="production"
# Self-host defaults
OPENFRAME_ENABLE_STRIPE="false"
OPENFRAME_ENABLE_BUNNY_UPLOADS="false"
OPENFRAME_REQUIRE_INVITE_CODE="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="true"
# MinIO-backed S3 storage
MINIO_ROOT_USER="replace-with-minio-root-user"
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
R2_ENDPOINT="http://minio:9000"
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
R2_SECRET_ACCESS_KEY="replace-with-strong-minio-password"
R2_BUCKET_NAME="openframe"
# Optional auth/admin configuration
ADMIN_EMAILS=""
INVITE_CODE=""
# Optional integrations. Leave blank to disable.
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASSWORD=""
SMTP_FROM=""
TELEGRAM_BOT_TOKEN=""
STRIPE_SECRET_KEY=""
STRIPE_PRICE_ID=""
STRIPE_WEBHOOK_SECRET=""
BUNNY_STREAM_API_KEY=""
BUNNY_STREAM_LIBRARY_ID=""
BUNNY_API_KEY=""
BUNNY_CDN_URL=""
NEXT_PUBLIC_BUNNY_CDN_URL=""
+4
View File
@@ -20,6 +20,7 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
OPENFRAME_ENABLE_STRIPE="true"
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
OPENFRAME_REQUIRE_INVITE_CODE="true"
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
# ============================================================================
# OAUTH PROVIDERS
@@ -38,7 +39,10 @@ GITHUB_CLIENT_SECRET="your-github-client-secret"
# FILE STORAGE
# ============================================================================
# Cloudflare R2 (S3-compatible)
# For self-hosted S3-compatible storage such as MinIO, set R2_ENDPOINT and R2_PUBLIC_BASE_URL.
R2_ACCOUNT_ID="your-account-id"
R2_ENDPOINT=""
R2_PUBLIC_BASE_URL=""
R2_ACCESS_KEY_ID="your-access-key"
R2_SECRET_ACCESS_KEY="your-secret-key"
R2_BUCKET_NAME="openframe"
+1
View File
@@ -33,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.docker.example
# vercel
.vercel
+59
View File
@@ -0,0 +1,59 @@
FROM oven/bun:1 AS base
WORKDIR /app
FROM base AS deps
COPY package.json bun.lock ./
COPY prisma ./prisma
RUN bun install --frozen-lockfile
FROM deps AS build
COPY app ./app
COPY components ./components
COPY lib ./lib
COPY prisma ./prisma
COPY public ./public
COPY scripts ./scripts
COPY types ./types
COPY components.json ./components.json
COPY next-env.d.ts ./next-env.d.ts
COPY next.config.ts ./next.config.ts
COPY postcss.config.mjs ./postcss.config.mjs
COPY prisma.config.ts ./prisma.config.ts
COPY tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
RUN bun run db:generate
RUN bun run build
FROM oven/bun:1 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/bun.lock ./bun.lock
COPY --from=build /app/next.config.ts ./next.config.ts
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/lib ./lib
COPY --from=build /app/app ./app
COPY --from=build /app/components ./components
COPY --from=build /app/types ./types
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/.next ./.next
COPY --from=build /app/tsconfig.json ./tsconfig.json
COPY --from=build /app/postcss.config.mjs ./postcss.config.mjs
COPY --from=build /app/components.json ./components.json
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
COPY --from=build /app/next-env.d.ts ./next-env.d.ts
RUN chmod +x /app/scripts/docker-entrypoint.sh
EXPOSE 3000
CMD ["bun", "run", "start:docker"]
+131 -16
View File
@@ -1,27 +1,124 @@
# OpenFrame
OpenFrame is a collaborative video feedback platform built with Next.js, Bun, Prisma, and PostgreSQL.
OpenFrame is an open source video review and approval platform for teams that need clear feedback, version control, and client-friendly review links in one place. It supports collaborative review workflows out of the box and can be self-hosted with the Docker setup included in this repository.
## Development
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial, then continue on the hosted plan starting at $10.
Install dependencies and run checks with Bun:
## Product Screenshot
![OpenFrame product screenshot](public/github-readme.jpeg)
## What OpenFrame Covers
OpenFrame is built for video teams that want one system for review, revision, approval, and delivery feedback.
- Timestamped comments directly on the video timeline
- Voice notes, image attachments, and frame annotations
- Version history with side-by-side compare
- Approval requests and sign-off tracking
- Share links for client review with optional guest commenting
- Workspaces, projects, member roles, and invitation flows
- Comment tags, resolved states, and CSV/PDF exports
- Video-linked assets for supporting media and references
- Email and Telegram notifications
- URL-based YouTube video intake plus optional Bunny direct uploads
## Core Workflow
1. Add a video to a project from a YouTube URL or direct upload flow.
2. Share a review link with internal collaborators or external stakeholders.
3. Collect timestamped feedback with text, voice, images, and annotations.
4. Compare versions, resolve comments, and request approvals.
5. Export feedback or keep everything tracked inside the project timeline.
## Features
### Review Without Guesswork
- Timestamped comments anchor every note to an exact moment in the cut.
- Reviewers can leave text, voice notes, image attachments, and drawn annotations.
- Comment threads support replies, resolution states, and project-specific tags.
### Versioning And Comparison
- Videos support multiple versions inside the same review thread.
- Teams can switch between versions without losing review context.
- Compare mode lets reviewers inspect two versions side by side.
### Client And Team Collaboration
- Share links can be configured for view or comment access.
- Guest review is supported for external stakeholders.
- Workspaces and projects support member roles, invitations, and scoped access.
### Approval And Reporting
- Approval requests can be sent to specific reviewers.
- Approval decisions are tracked per request with pending, approved, rejected, and canceled states.
- Comments can be exported as CSV or PDF for offline review and handoff.
### Assets, Notifications, And Integrations
- Videos can include related assets such as images, supplementary videos, and audio.
- Notification settings support email and Telegram delivery.
- Self-hosted setups can run with bundled S3-compatible storage or external object storage.
- Optional integrations include Stripe billing, Bunny direct uploads, OAuth providers, SMTP, and Telegram notifications.
## Stack
OpenFrame is built with:
- Next.js 16 and React 19
- Bun
- TypeScript
- Prisma
- PostgreSQL
- NextAuth.js
- Tailwind CSS
- MinIO or other S3-compatible object storage for self-hosted media
- Bunny Stream for optional direct video uploads
## Self-Hosting
OpenFrame ships with a Docker Compose setup for self-hosting. The default stack brings up:
- OpenFrame on `http://localhost:3000`
- PostgreSQL for the application database
- MinIO for S3-compatible object storage
### Quick Start
```bash
bun install
bun run check
cp .env.docker.example .env.docker
```
## Self-hosting flags
OpenFrame supports env flags so self-hosted installs can disable hosted-only features without code changes:
Edit `.env.docker`, set strong values for `NEXTAUTH_SECRET`, `POSTGRES_PASSWORD`, and the MinIO credentials, then start the stack:
```bash
OPENFRAME_ENABLE_STRIPE=true
OPENFRAME_ENABLE_BUNNY_UPLOADS=true
OPENFRAME_REQUIRE_INVITE_CODE=true
docker compose up --build
```
Recommended self-hosted values for a single-team deployment:
Open `http://localhost:3000` after the containers become healthy.
MinIO is bound to `127.0.0.1` by default, so the S3 API and admin console stay local to the host unless you intentionally re-publish those ports.
The Docker template already trusts `localhost:3000` for Auth.js via `AUTH_TRUST_HOST=true`, so the default local Compose flow does not require extra auth host setup.
### First Boot Behavior
- The app waits for PostgreSQL and MinIO before starting.
- Prisma migrations run automatically on container boot.
- The MinIO bucket is created automatically when `SELF_HOSTED_AUTO_CREATE_BUCKET=true`.
### Persistence And Upgrades
- PostgreSQL data is stored in the `postgres-data` Docker volume.
- MinIO objects are stored in the `minio-data` Docker volume.
- After updating the repo, rebuild and restart with `docker compose up --build`.
### Optional Integrations And Feature Flags
The Docker example disables hosted-only features by default:
```bash
OPENFRAME_ENABLE_STRIPE=false
@@ -31,8 +128,26 @@ OPENFRAME_REQUIRE_INVITE_CODE=false
Behavior when disabled:
- `OPENFRAME_ENABLE_STRIPE=false`: disables Stripe checkout and portal flows and removes billing-based workspace restrictions.
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false`: hides direct-upload entry points. URL-based providers such as YouTube continue to work.
- `OPENFRAME_REQUIRE_INVITE_CODE=false`: allows open registration while keeping invitation-link registration intact.
- `OPENFRAME_ENABLE_STRIPE=false` disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions.
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides direct-upload entry points. URL-based providers such as YouTube remain available.
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
Feature flags are documented in `.env.example`. Hosted defaults remain enabled.
These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:
- Stripe billing
- Bunny direct uploads
- SMTP for invitation and notification delivery
- Telegram notifications
- External S3-compatible storage such as Cloudflare R2 or another compatible provider instead of bundled MinIO
- Google and GitHub OAuth
## Development
Install dependencies and run validation with Bun:
```bash
bun install
bun run check
```
Feature flags and self-hosting environment variables are documented in `.env.example` and `.env.docker.example`.
+53
View File
@@ -0,0 +1,53 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
env_file:
- .env.docker
environment:
DOCKER_DB_HOST: postgres
DOCKER_DB_PORT: "5432"
MINIO_HEALTHCHECK_URL: http://minio:9000/minio/health/live
ports:
- "3000:3000"
restart: unless-stopped
postgres:
image: postgres:16-alpine
env_file:
- .env.docker
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
env_file:
- .env.docker
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 20
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
volumes:
- minio-data:/data
restart: unless-stopped
volumes:
postgres-data:
minio-data:
+101 -14
View File
@@ -1,20 +1,109 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import {
CreateBucketCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY!;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME!;
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? '';
const R2_ENDPOINT = process.env.R2_ENDPOINT;
const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL;
export const r2Client = new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
let cachedR2Client: S3Client | null = null;
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
function requireStorageValue(name: string, value: string | undefined): string {
if (!value) {
throw new Error(`Missing ${name} for S3-compatible storage`);
}
return value;
}
function getR2Endpoint(): string {
if (R2_ENDPOINT) {
return trimTrailingSlashes(R2_ENDPOINT);
}
if (!R2_ACCOUNT_ID) {
throw new Error('Missing R2_ENDPOINT or R2_ACCOUNT_ID for S3-compatible storage');
}
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
}
function getOrCreateR2Client(): S3Client {
if (cachedR2Client) {
return cachedR2Client;
}
cachedR2Client = new S3Client({
region: 'auto',
endpoint: getR2Endpoint(),
forcePathStyle: Boolean(R2_ENDPOINT),
credentials: {
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
},
});
return cachedR2Client;
}
export const r2Client = new Proxy({} as S3Client, {
get(_target, prop, receiver) {
if (prop === 'destroy') {
return () => {
if (!cachedR2Client) return;
cachedR2Client.destroy();
cachedR2Client = null;
};
}
const client = getOrCreateR2Client();
const value = Reflect.get(client, prop, receiver);
return typeof value === 'function' ? value.bind(client) : value;
},
});
export function getR2PublicObjectUrl(key: string): string {
const sanitizedKey = key.replace(/^\/+/, '');
if (R2_PUBLIC_BASE_URL) {
return `${trimTrailingSlashes(R2_PUBLIC_BASE_URL)}/${sanitizedKey}`;
}
if (R2_ENDPOINT) {
return `${trimTrailingSlashes(R2_ENDPOINT)}/${R2_BUCKET_NAME}/${sanitizedKey}`;
}
if (!R2_ACCOUNT_ID) {
throw new Error('Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID for public object URLs');
}
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${sanitizedKey}`;
}
export async function ensureR2BucketExists(): Promise<void> {
try {
await r2Client.send(new HeadBucketCommand({ Bucket: R2_BUCKET_NAME }));
return;
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (statusCode && statusCode !== 404 && statusCode !== 301 && statusCode !== 403) {
throw error;
}
}
await r2Client.send(new CreateBucketCommand({ Bucket: R2_BUCKET_NAME }));
}
export async function uploadAudio(
buffer: Buffer,
filename: string,
@@ -34,9 +123,7 @@ export async function uploadAudio(
})
);
// R2 public URL — uses the R2.dev subdomain or custom domain
// For development, we use the R2.dev auto-generated URL
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`;
return getR2PublicObjectUrl(key);
}
export { R2_BUCKET_NAME };
+2
View File
@@ -7,6 +7,7 @@
"prebuild": "bun run typecheck",
"build": "next build",
"start": "next start",
"start:docker": "sh ./scripts/docker-entrypoint.sh",
"lint": "eslint --max-warnings=0",
"typecheck": "tsc --noEmit",
"check": "bun run lint && bun run typecheck",
@@ -16,6 +17,7 @@
"db:migrate": "prisma migrate deploy",
"db:seed": "prisma db seed",
"db:setup": "bun run db:generate && bun run db:migrate",
"self-host:bootstrap": "bun run scripts/self-host-bootstrap.ts",
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+152
View File
@@ -0,0 +1,152 @@
import 'dotenv/config';
import { Client } from 'pg';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
type MigrationRow = {
migration_name: string;
finished_at: Date | null;
rolled_back_at: Date | null;
};
async function runPrisma(args: string[]) {
await new Promise<void>((resolve, reject) => {
const proc = spawn('./node_modules/.bin/prisma', args, {
stdio: 'inherit',
env: process.env,
});
proc.on('error', reject);
proc.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Prisma command failed: prisma ${args.join(' ')}`));
});
});
}
async function tableExists(client: Client, tableName: string) {
const result = await client.query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) AS "exists"
`,
[tableName]
);
return result.rows[0]?.exists ?? false;
}
async function getMigrationRows(client: Client) {
const hasMigrationsTable = await tableExists(client, '_prisma_migrations');
if (!hasMigrationsTable) return [];
const result = await client.query<MigrationRow>(
`
SELECT migration_name, finished_at, rolled_back_at
FROM "_prisma_migrations"
ORDER BY started_at ASC
`
);
return result.rows;
}
function getMigrationDirectories() {
return readdirSync(join(process.cwd(), 'prisma', 'migrations'), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
async function getPublicTables(client: Client) {
const result = await client.query<{ table_name: string }>(
`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name ASC
`
);
return result.rows.map((row) => row.table_name);
}
async function main() {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
}
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const [publicTables, migrationRows] = await Promise.all([
getPublicTables(client),
getMigrationRows(client),
]);
const appTables = publicTables.filter((table) => table !== '_prisma_migrations');
const hasUsersTable = appTables.includes('users');
const hasWorkspacesTable = appTables.includes('workspaces');
const hasProjectsTable = appTables.includes('projects');
const hasCoreTables = hasUsersTable || hasWorkspacesTable || hasProjectsTable;
const failedMigrations = migrationRows.filter((row) => !row.finished_at && !row.rolled_back_at);
const shouldBootstrapFreshSchema = !hasCoreTables;
console.log(`Detected public tables: ${appTables.length > 0 ? appTables.join(', ') : '(none)'}`);
if (shouldBootstrapFreshSchema) {
if (failedMigrations.length > 0) {
console.log('Detected failed migration state on a fresh database. Marking failed migrations as rolled back.');
for (const migration of failedMigrations) {
await runPrisma(['migrate', 'resolve', '--rolled-back', migration.migration_name]);
}
}
console.log('Fresh self-hosted database detected. Synchronizing schema baseline.');
await runPrisma(['db', 'push']);
const appliedMigrationNames = new Set(
migrationRows
.filter((row) => row.finished_at && !row.rolled_back_at)
.map((row) => row.migration_name)
);
for (const migrationName of getMigrationDirectories()) {
if (appliedMigrationNames.has(migrationName)) continue;
await runPrisma(['migrate', 'resolve', '--applied', migrationName]);
}
console.log('Fresh database bootstrap complete');
return;
}
if (failedMigrations.length > 0) {
throw new Error(
`Detected failed Prisma migrations on a non-empty database: ${failedMigrations
.map((migration) => migration.migration_name)
.join(', ')}`
);
}
console.log('Running Prisma migrations');
await runPrisma(['migrate', 'deploy']);
} finally {
await client.end();
}
}
main().catch((error) => {
console.error('Docker database bootstrap failed:', error);
process.exit(1);
});
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env sh
set -eu
DB_HOST="${DOCKER_DB_HOST:-postgres}"
DB_PORT="${DOCKER_DB_PORT:-5432}"
MINIO_HEALTHCHECK_URL="${MINIO_HEALTHCHECK_URL:-http://minio:9000/minio/health/live}"
MAX_ATTEMPTS="${STARTUP_MAX_ATTEMPTS:-60}"
SLEEP_SECONDS="${STARTUP_SLEEP_SECONDS:-2}"
wait_for_tcp() {
host="$1"
port="$2"
label="$3"
attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if nc -z "$host" "$port" >/dev/null 2>&1; then
echo "$label is reachable at $host:$port"
return 0
fi
echo "Waiting for $label at $host:$port ($attempt/$MAX_ATTEMPTS)"
attempt=$((attempt + 1))
sleep "$SLEEP_SECONDS"
done
echo "Timed out waiting for $label at $host:$port" >&2
exit 1
}
wait_for_http() {
url="$1"
label="$2"
attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
if curl --silent --fail "$url" >/dev/null 2>&1; then
echo "$label is reachable at $url"
return 0
fi
echo "Waiting for $label at $url ($attempt/$MAX_ATTEMPTS)"
attempt=$((attempt + 1))
sleep "$SLEEP_SECONDS"
done
echo "Timed out waiting for $label at $url" >&2
exit 1
}
wait_for_tcp "$DB_HOST" "$DB_PORT" "Postgres"
wait_for_http "$MINIO_HEALTHCHECK_URL" "MinIO"
echo "Bootstrapping database"
bun run scripts/docker-db-bootstrap.ts
echo "Running self-host bootstrap"
bun run self-host:bootstrap
echo "Starting OpenFrame"
exec bun run start
+20
View File
@@ -0,0 +1,20 @@
import 'dotenv/config';
import { ensureR2BucketExists, R2_BUCKET_NAME } from '@/lib/r2';
const shouldCreateBucket = /^(1|true|yes|on)$/i.test(process.env.SELF_HOSTED_AUTO_CREATE_BUCKET ?? '');
async function main() {
if (!shouldCreateBucket) {
console.log('Skipping self-host bucket bootstrap');
return;
}
console.log(`Ensuring object storage bucket exists: ${R2_BUCKET_NAME}`);
await ensureR2BucketExists();
console.log(`Bucket is ready: ${R2_BUCKET_NAME}`);
}
main().catch((error) => {
console.error('Self-host bootstrap failed:', error);
process.exit(1);
});