Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,15 +1,63 @@
node_modules
.next
.git
src
e2e
__tests__
tools
agent-ctx
coverage
test-results
*.md
!DEPLOY.md
.env*
!.env.example
supabase/.temp
# Dependencies
node_modules/


# Build output (recreated in container)
.next/
out/


# Environment files
.env
.env.*


# Internal directories
engineering/
registry/
agent-ctx/
ferrum-platform/
skills/
tool-results/


# Test artifacts
coverage/
test-results/
playwright-report/


# Git
.git/


# Editor
.vscode/
.idea/


# OS
.DS_Store
Thumbs.db


# Sandbox artifacts
screenshots/
examples/
mini-services/
.zscripts/
upload/
download/


# Misc
db/*.db
db/*.json
*.log
.budget-baseline.json
bun.lock
pnpm-lock.yaml
worklog.md
*.bak
*.zip
*.tar.gz
90 changes: 57 additions & 33 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
workflow_run:
workflows: [CI]
workflows: ["CI"]
types: [completed]
branches: [main]

Expand All @@ -14,46 +14,70 @@ concurrency:
cancel-in-progress: false

jobs:
deploy:
name: Deploy to Production
# ── Docker Build & Push ──────────────────────────────────────
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 15
if: ${{ github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' }}
environment: production
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
node-version: 22
cache: npm
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Install dependencies
run: npm ci
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}

- name: Build Standalone
env:
DATABASE_URL: file:/tmp/test.db
CLOUD_API_TOKEN: ${{ secrets.CLOUD_API_TOKEN }}
CLOUD_ADMIN_PASSWORD: ${{ secrets.CLOUD_ADMIN_PASSWORD }}
run: npm run build
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: runner

- name: Verify build artifacts
run: |
if [ ! -d ".next/standalone" ]; then
echo "FAIL: Standalone output not found"
exit 1
fi
echo "PASS: Standalone build ready"
du -sh .next/standalone

- name: Deploy
# ── Verify deployment ───────────────────────────────────────
verify:
name: Verify Deploy
runs-on: ubuntu-latest
needs: [docker]
if: ${{ github.event_name == 'push' }}
steps:
- name: Deploy Summary
run: |
echo "Deploy step — configure for your hosting provider"
echo "Supported targets: Vercel, Docker, AWS ECS, Railway"
echo "Standalone output is at .next/standalone/"
# Example: vercel deploy --prod
# Example: docker build -t ferrumengine .
# Example: fly deploy
echo "## Deployment Summary :shipit:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`ghcr.io/${{ github.repository }}:latest\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Author** | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY
echo "| **Time** | $(date -u '+%Y-%m-%d %H:%M UTC') |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull & Run" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ghcr.io/${{ github.repository }}:latest" >> $GITHUB_STEP_SUMMARY
echo "docker run -p 3000:3000 --env-file .env ghcr.io/${{ github.repository }}:latest" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ jobs:
path: ./release-artifacts/
- name: Create tarball
working-directory: ./release-artifacts
run: tar -czf ../ferrumcss-${{ steps.version.outputs.VERSION }}.tar.gz .next/ public/
run: tar -czf ../ferrumengine-${{ steps.version.outputs.VERSION }}.tar.gz .next/ public/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
Expand All @@ -120,5 +120,5 @@ jobs:
body: ${{ steps.changelog.outputs.BODY }}
draft: false
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
files: ferrumcss-${{ steps.version.outputs.VERSION }}.tar.gz
files: ferrumengine-${{ steps.version.outputs.VERSION }}.tar.gz
generate_release_notes: false
76 changes: 61 additions & 15 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
:8080 {
encode gzip
handle {
# Override Next.js 16 auto-CSP to allow inline RSC scripts.
# Must be inside handle block before reverse_proxy.
header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' blob:; base-uri 'self'; form-action 'self'"

reverse_proxy 127.0.0.1:3000 {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
}
}
# ═══════════════════════════════════════════════════════════════
# FerrumEngine — Caddy Configuration (Production)
# ═══════════════════════════════════════════════════════════════
# Usage:
# caddy run # Auto-HTTPS via Let's Encrypt
# caddy run --adapter json # JSON config
#
# Environment variables:
# FERRUM_PORT — listen port (default: 8080)
# FERRUM_HOST — upstream host (default: 127.0.0.1:3000)
# ═══════════════════════════════════════════════════════════════

{$FERRUM_PORT:8080} {
# ── Compression ─────────────────────────────────────────
encode gzip zstd

# ── Override Next.js CSP for RSC inline scripts ──────────
header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' blob:; base-uri 'self'; form-action 'self'"

# ── HSTS (Caddy auto-redirects HTTP→HTTPS, this enforces) ──
header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

# ── Static assets: long cache ────────────────────────────
handle_path /_next/static/* {
header Cache-Control "public, max-age=31536000, immutable"
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000}
}

handle_path /worklets/*.js {
header Cache-Control "public, max-age=86400, stale-while-revalidate=604800"
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000}
}

# ── HTML pages: short cache for SPA rewrites ──────────────
handle_path /ferrum-effects.css {
header Cache-Control "public, max-age=86400, stale-while-revalidate=604800"
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000}
}

# ── API routes: no cache ─────────────────────────────────
handle_path /api/* {
header Cache-Control "no-store, no-cache, must-revalidate"
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000}
}

# ── Health check: no cache ────────────────────────────────
handle_path /api/health {
header Cache-Control "no-store"
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000}
}

# ── Everything else ───────────────────────────────────────
handle {
reverse_proxy {$FERRUM_HOST:127.0.0.1:3000} {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
}
}
81 changes: 67 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,29 +1,82 @@
FROM node:18-alpine AS base
# ═══════════════════════════════════════════════════════════════
# FerrumEngine — Multi-stage Docker Build (Standalone Output)
# ═══════════════════════════════════════════════════════════════
# Usage:
# docker build -t ferrumengine .
# docker run -p 3000:3000 --env-file .env.production ferrumengine
#
# Production (standalone — smaller image):
# docker build --target runner -t ferrumengine:prod .
# ═══════════════════════════════════════════════════════════════

# Install dependencies only when needed
FROM base AS deps
# ── Stage 1: Dependencies ──────────────────────────────────────
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
RUN npm ci

# Production image
FROM base AS runner
# ── Stage 2: Build ─────────────────────────────────────────────
FROM deps AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV production
# Build args for environment variables at build time
ARG DATABASE_URL="file:/dev/null"
ARG CLOUD_API_TOKEN=""
ARG CLOUD_ADMIN_PASSWORD=""
ARG NEXT_PUBLIC_SUPABASE_URL=""
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=""
ARG SUPABASE_SERVICE_ROLE_KEY=""
ARG TRUSTED_PROXY_IPS=""

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
ENV DATABASE_URL=${DATABASE_URL}
ENV CLOUD_API_TOKEN=${CLOUD_API_TOKEN}
ENV CLOUD_ADMIN_PASSWORD=${CLOUD_ADMIN_PASSWORD}
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
ENV SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
ENV TRUSTED_PROXY_IPS=${TRUSTED_PROXY_IPS}
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── Stage 3: Production Runner (Standalone) ────────────────────
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs

# Copy standalone output
COPY --from=builder /app/.next/standalone ./

# Copy static assets (public/ + .next/static/)
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static

# Copy worklet files for Paint API
COPY --from=builder /app/public/worklets ./public/worklets

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1

CMD ["node", "server.js"]

CMD ["node_modules/.bin/next", "start"]
# ── Stage 4: Development ───────────────────────────────────────
FROM deps AS dev
WORKDIR /app
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
Loading