Improve Coolify deploy build caching
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# Build output and local caches
|
# Build output and local caches
|
||||||
.next
|
.next
|
||||||
.cache
|
.cache
|
||||||
|
.swc
|
||||||
|
.workflow-data
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
@@ -18,3 +20,13 @@ data
|
|||||||
# Git
|
# Git
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
|
||||||
|
# Test and local tooling artifacts
|
||||||
|
.playwright-cli
|
||||||
|
e2e
|
||||||
|
output
|
||||||
|
|
||||||
|
# Docs and generated metadata
|
||||||
|
README.md
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ WORKFLOW_TARGET_WORLD=local
|
|||||||
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
||||||
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
||||||
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
||||||
|
RUN_WORKFLOW_SETUP_ON_START=true
|
||||||
|
RUN_DB_MIGRATIONS_ON_START=true
|
||||||
|
|
||||||
# Optional local-world fallback for rollback/testing
|
# Optional local-world fallback for rollback/testing
|
||||||
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,7 +1,10 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM oven/bun:1.3.5-alpine AS deps
|
FROM oven/bun:1.3.5-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
ARG NEXT_PUBLIC_API_URL=
|
ARG NEXT_PUBLIC_API_URL=
|
||||||
@@ -11,8 +14,11 @@ ENV DATABASE_URL=${DATABASE_URL}
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
||||||
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
||||||
|
ENV RUN_WORKFLOW_SETUP_ON_START=true
|
||||||
|
ENV RUN_DB_MIGRATIONS_ON_START=true
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p public /app/.workflow-data && bun run build
|
RUN --mount=type=cache,target=/app/.next/cache \
|
||||||
|
mkdir -p public /app/.workflow-data && bun run build
|
||||||
|
|
||||||
FROM oven/bun:1.3.5-alpine AS runner
|
FROM oven/bun:1.3.5-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -23,12 +29,13 @@ ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
||||||
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
||||||
|
ENV RUN_WORKFLOW_SETUP_ON_START=true
|
||||||
|
ENV RUN_DB_MIGRATIONS_ON_START=true
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/drizzle ./drizzle
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
|
||||||
COPY --from=builder /app/scripts ./scripts
|
COPY --from=builder /app/scripts ./scripts
|
||||||
COPY --from=builder /app/lib ./lib
|
COPY --from=builder /app/lib ./lib
|
||||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
@@ -42,4 +49,4 @@ EXPOSE 3000
|
|||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "if [ \"$WORKFLOW_TARGET_WORLD\" = \"@workflow/world-postgres\" ]; then ./node_modules/.bin/workflow-postgres-setup; fi && ./node_modules/.bin/drizzle-kit migrate --config /app/drizzle.config.ts && bun server.js"]
|
CMD ["sh", "-c", "bun run bootstrap:prod && bun server.js"]
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -81,10 +81,16 @@ On container startup, the app applies Drizzle migrations automatically before la
|
|||||||
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow world data in Postgres volume `workflow_postgres_data`.
|
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow world data in Postgres volume `workflow_postgres_data`.
|
||||||
Container startup runs:
|
Container startup runs:
|
||||||
1. `workflow-postgres-setup` (idempotent Workflow world bootstrap)
|
1. `workflow-postgres-setup` (idempotent Workflow world bootstrap)
|
||||||
2. Drizzle migrations for SQLite app tables
|
2. Programmatic Drizzle migrations for SQLite app tables
|
||||||
3. Next.js server boot
|
3. Next.js server boot
|
||||||
|
|
||||||
Docker images use Bun (`oven/bun:1.3.5-alpine`) for build and runtime.
|
Docker images use Bun (`oven/bun:1.3.5-alpine`) for build and runtime.
|
||||||
|
Docker builds use BuildKit cache mounts for Bun downloads and `.next/cache`, so repeated server-side builds can reuse dependency and Next/Turbopack caches on the same builder.
|
||||||
|
|
||||||
|
Optional runtime toggles:
|
||||||
|
|
||||||
|
- `RUN_WORKFLOW_SETUP_ON_START=true` keeps `workflow-postgres-setup` enabled at container boot.
|
||||||
|
- `RUN_DB_MIGRATIONS_ON_START=true` keeps SQLite migrations enabled at container boot.
|
||||||
|
|
||||||
## Coolify deployment
|
## Coolify deployment
|
||||||
|
|
||||||
@@ -107,6 +113,9 @@ Operational constraints for Coolify:
|
|||||||
- Ensure both named volumes are persisted (`fiscal_sqlite_data`, `workflow_postgres_data`).
|
- Ensure both named volumes are persisted (`fiscal_sqlite_data`, `workflow_postgres_data`).
|
||||||
- Keep `WORKFLOW_POSTGRES_URL` explicit so Workflow does not fall back to `DATABASE_URL` (SQLite).
|
- Keep `WORKFLOW_POSTGRES_URL` explicit so Workflow does not fall back to `DATABASE_URL` (SQLite).
|
||||||
- The app `/api/health` probes Workflow backend connectivity and returns non-200 when Workflow world is unavailable.
|
- The app `/api/health` probes Workflow backend connectivity and returns non-200 when Workflow world is unavailable.
|
||||||
|
- Keep `Include Source Commit in Build` disabled so Docker layer cache stays reusable between commits.
|
||||||
|
- Keep Docker cleanup threshold-based rather than aggressive, otherwise Coolify will discard build cache.
|
||||||
|
- Keep repeated builds pinned to the same builder/server when possible so Docker layer cache and BuildKit cache mounts remain warm.
|
||||||
|
|
||||||
Emergency rollback path:
|
Emergency rollback path:
|
||||||
|
|
||||||
@@ -140,6 +149,8 @@ WORKFLOW_TARGET_WORLD=local
|
|||||||
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
||||||
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
||||||
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
||||||
|
RUN_WORKFLOW_SETUP_ON_START=true
|
||||||
|
RUN_DB_MIGRATIONS_ON_START=true
|
||||||
|
|
||||||
# Optional local-world fallback
|
# Optional local-world fallback
|
||||||
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "bun run scripts/dev.ts",
|
"dev": "bun run scripts/dev.ts",
|
||||||
"dev:next": "bun --bun next dev --turbopack",
|
"dev:next": "bun --bun next dev --turbopack",
|
||||||
"build": "bun --bun next build --turbopack",
|
"build": "bun --bun next build --turbopack",
|
||||||
|
"bootstrap:prod": "bun run scripts/bootstrap-production.ts",
|
||||||
"start": "bun --bun next start",
|
"start": "bun --bun next start",
|
||||||
"lint": "bun x tsc --noEmit",
|
"lint": "bun x tsc --noEmit",
|
||||||
"e2e:prepare": "bun run scripts/e2e-prepare.ts",
|
"e2e:prepare": "bun run scripts/e2e-prepare.ts",
|
||||||
|
|||||||
110
scripts/bootstrap-production.ts
Normal file
110
scripts/bootstrap-production.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||||
|
import { resolveSqlitePath } from './dev-env';
|
||||||
|
|
||||||
|
function trim(value: string | undefined) {
|
||||||
|
const candidate = value?.trim();
|
||||||
|
return candidate ? candidate : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRun(value: string | undefined) {
|
||||||
|
return trim(value) !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message: string) {
|
||||||
|
console.info(`[bootstrap ${new Date().toISOString()}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: number) {
|
||||||
|
return `${(performance.now() - startedAt).toFixed(1)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDatabasePath() {
|
||||||
|
const raw = trim(process.env.DATABASE_URL) || 'file:data/fiscal.sqlite';
|
||||||
|
let databasePath = raw.startsWith('file:') ? raw.slice(5) : raw;
|
||||||
|
|
||||||
|
if (databasePath.startsWith('///')) {
|
||||||
|
databasePath = databasePath.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databasePath) {
|
||||||
|
throw new Error('DATABASE_URL must point to a SQLite file path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databasePath.includes('://')) {
|
||||||
|
throw new Error(`DATABASE_URL must resolve to a SQLite file path. Received: ${raw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWorkflowSetup() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const result = spawnSync('./node_modules/.bin/workflow-postgres-setup', [], {
|
||||||
|
env: process.env,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`workflow-postgres-setup failed with exit code ${result.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`workflow-postgres-setup completed in ${formatDuration(startedAt)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDatabaseMigrations() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const databasePath = getDatabasePath();
|
||||||
|
|
||||||
|
if (databasePath !== ':memory:') {
|
||||||
|
const normalizedPath = resolveSqlitePath(databasePath);
|
||||||
|
mkdirSync(dirname(normalizedPath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Database(databasePath, { create: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
migrate(drizzle(client), { migrationsFolder: './drizzle' });
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`database migrations completed in ${formatDuration(startedAt)} (${databasePath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStartedAt = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shouldRunWorkflowSetup = shouldRun(process.env.RUN_WORKFLOW_SETUP_ON_START)
|
||||||
|
&& trim(process.env.WORKFLOW_TARGET_WORLD) === '@workflow/world-postgres';
|
||||||
|
const shouldRunMigrations = shouldRun(process.env.RUN_DB_MIGRATIONS_ON_START);
|
||||||
|
|
||||||
|
log('starting production bootstrap');
|
||||||
|
|
||||||
|
if (shouldRunWorkflowSetup) {
|
||||||
|
runWorkflowSetup();
|
||||||
|
} else {
|
||||||
|
log('workflow-postgres-setup skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRunMigrations) {
|
||||||
|
runDatabaseMigrations();
|
||||||
|
} else {
|
||||||
|
log('database migrations skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`production bootstrap completed in ${formatDuration(totalStartedAt)}`);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
log(`production bootstrap failed after ${formatDuration(totalStartedAt)}: ${reason}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user