From 9ce0fdca955fced29f2b8267f62d553f7c661a07 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 22 Sep 2026 17:09:30 +0100 Subject: [PATCH 1/3] fix(node): serve the root-level favicon and dashboard icons Hono has no suffix matching, so the '/*.svg' and '/*.ico' static routes never matched and every root-level asset vite copies out of public/ returned 404. Mount them by path instead, and lift the duplicated client root into a const. --- apps/node/src/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/node/src/index.ts b/apps/node/src/index.ts index c1dc488f..7bbfe288 100644 --- a/apps/node/src/index.ts +++ b/apps/node/src/index.ts @@ -58,9 +58,14 @@ app.onError((err, c) => { console.error('HONO ERROR:', err); return c.text('Custom Error: ' + err.message, 500); }); -app.use('/assets/*', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); -app.use('/*.svg', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); -app.use('/*.ico', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); +const clientRoot = process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client'; + +app.use('/assets/*', serveStatic({ root: clientRoot })); +// Hono has no suffix matching, so '/*.svg' and '/*.ico' never matched: the root-level +// icons vite copies out of public/ are mounted by their own paths instead. +app.use('/icons/*', serveStatic({ root: clientRoot })); +app.use('/favicon.ico', serveStatic({ root: clientRoot })); +app.use('/favicon.svg', serveStatic({ root: clientRoot })); const port = parseInt(process.env.PORT || '3000', 10); serve({ From 21e71f3dc0ede71945ad8dd4290e4dd8bf01134a Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 22 Sep 2026 17:09:37 +0100 Subject: [PATCH 2/3] feat(node): add Docker image and self-hosting compose stack Closes #108. apps/node/Dockerfile builds the dashboard and the server bundle in a multi-stage build and ships a runner carrying only the bundle, dist/client, and the node-server workspace's production dependencies (191MB image, 27MB of node_modules). It calls 'npx vite build' rather than the root build script, which chains cf-typegen and would drag wrangler into the image. docker-compose.yml brings up Postgres, Redis, and the app. Migrations are not part of the server bundle, so a one-shot migrate service runs them from the same image and the app gates on it completing; Postgres and Redis are gated on healthchecks rather than bare depends_on, and both are published on 127.0.0.1 so a VPS deploy does not expose them. .env.docker.example documents every variable the container needs to boot. --- .dockerignore | 27 ++++++++++++++ .env.docker.example | 47 ++++++++++++++++++++++++ .gitignore | 1 + apps/node/Dockerfile | 79 ++++++++++++++++++++++++++++++++++++++++ apps/node/README.md | 85 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 86 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.docker.example create mode 100644 apps/node/Dockerfile create mode 100644 apps/node/README.md create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f3fede8e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Build context trimming for apps/node/Dockerfile. +# node_modules and dist are rebuilt inside the image; secrets must never enter it. + +node_modules +**/node_modules +dist +**/dist +.wrangler +**/.wrangler + +.git +.github +.changeset + +.dev.vars +.env +.env.* +!.env.docker.example + +*.log +npm-debug.log* +coverage +.vscode +.idea +.DS_Store + +test diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 00000000..0e42949f --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,47 @@ +# Codra self-hosted (Docker) environment example. +# Copy to .env before running `docker compose up -d`: +# +# cp .env.docker.example .env +# +# Keep real secrets in .env only. It is gitignored and excluded from the image. + +# --- Database and queue --- +# Defaults point at the postgres and redis services in docker-compose.yml. +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="codra" +DATABASE_URL="postgres://postgres:postgres@postgres:5432/codra" +REDIS_URL="redis://redis:6379" + +# --- Application URLs --- +# Set both to your public URL when deploying behind a domain. +APP_URL="http://localhost:3000" +AUTH_CALLBACK_URL="http://localhost:3000/auth/github/callback" +PORT="3000" +ENVIRONMENT="production" + +# --- GitHub App (webhooks, checks, reviews) --- +GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID" +GITHUB_APP_SLUG="REPLACE_WITH_YOUR_APP_SLUG" +GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" +APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" + +# --- GitHub OAuth (dashboard sign-in) --- +GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID" +GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" + +# Comma-separated GitHub usernames allowed into the dashboard. +# Set this to your own username, or nobody on your install can sign in. +DASHBOARD_ALLOWED_USERS="REPLACE_WITH_YOUR_GITHUB_USERNAME" + +# The bot account name Codra posts reviews as (usually "[bot]"). +BOT_USERNAME="REPLACE_WITH_YOUR_BOT_USERNAME" + +# --- LLM provider config encryption --- +# Encrypts dashboard-managed provider API keys at rest. Generate with: +# openssl rand -base64 48 +LLM_CONFIG_ENCRYPTION_KEY="REPLACE_WITH_A_LONG_RANDOM_ENCRYPTION_KEY" + +# --- Telemetry (optional) --- +# Anonymous aggregate usage stats are sent to https://codra.run/api/telemetry. +# TELEMETRY_DISABLED="true" diff --git a/.gitignore b/.gitignore index e22ce89b..636bc968 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ web_modules/ .env.* !.env.example !.env.test.example +!.env.docker.example # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/apps/node/Dockerfile b/apps/node/Dockerfile new file mode 100644 index 00000000..ba58cfae --- /dev/null +++ b/apps/node/Dockerfile @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1 + +# Production image for the Node deployment target (@codraoss/node-server). +# Build context is the repository root: docker build -f apps/node/Dockerfile . + +# ── Stage 1: workspace install ──────────────────────────────────────────────── +# Manifests are copied before the sources so npm ci stays cached until a +# dependency actually changes. +FROM node:22-alpine AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/models/package.json packages/models/ +COPY packages/provider-github/package.json packages/provider-github/ +COPY packages/schema/package.json packages/schema/ +COPY packages/ui/package.json packages/ui/ +COPY apps/node/package.json apps/node/ +COPY apps/worker/package.json apps/worker/ + +RUN npm ci + +# ── Stage 2: build ──────────────────────────────────────────────────────────── +# Two artifacts: the dashboard bundle at dist/client (served by the Node app) +# and the server bundle at apps/node/dist. +# +# `npx vite build` rather than `npm run build`: the root build script chains +# cf-typegen, which shells out to wrangler and has no place in this image. +FROM deps AS builder +WORKDIR /app + +COPY . . + +RUN npx vite build +RUN npm run build --workspace=@codraoss/node-server + +# ── Stage 3: runtime dependencies ───────────────────────────────────────────── +# tsup bundles every @codraoss/* package into the server bundle, so only the +# node-server workspace's own runtime deps are installed here. +FROM node:22-alpine AS prod-deps +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/models/package.json packages/models/ +COPY packages/provider-github/package.json packages/provider-github/ +COPY packages/schema/package.json packages/schema/ +COPY packages/ui/package.json packages/ui/ +COPY apps/node/package.json apps/node/ +COPY apps/worker/package.json apps/worker/ + +RUN npm ci --omit=dev --workspace=@codraoss/node-server + +# ── Stage 4: runner ─────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=builder /app/apps/node/dist ./dist +COPY --from=builder /app/dist/client ./dist/client + +# The migrate service in docker-compose.yml runs from this same image; the +# migration runner is a plain script and is not part of the server bundle. +COPY --from=builder /app/packages/db/package.json ./packages/db/package.json +COPY --from=builder /app/packages/db/scripts ./packages/db/scripts +COPY --from=builder /app/packages/db/migrations ./packages/db/migrations + +USER node + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/apps/node/README.md b/apps/node/README.md new file mode 100644 index 00000000..5609afe5 --- /dev/null +++ b/apps/node/README.md @@ -0,0 +1,85 @@ +# @codraoss/node-server + +The Node.js deployment target for Codra. Runs the same API, dashboard, and review +engine as the Cloudflare Worker, backed by Postgres and Redis instead of +Hyperdrive, KV, and Cloudflare Queues. + +Use this when you want Codra on a VM, a VPS, or any platform that runs Docker +(Coolify, Railway, Render, Fly.io). + +## Run the stack with Docker + +From the repository root: + +```bash +cp .env.docker.example .env +# fill in the GitHub App and OAuth values, then: +docker compose up -d +``` + +The dashboard is at . + +`docker compose up` starts four things: + +| Service | What it does | +| ----------- | --------------------------------------------------------- | +| `postgres` | Database, on a named volume so data survives restarts | +| `redis` | Session storage, config cache, and the review job queue | +| `migrate` | Applies `packages/db/migrations`, then exits | +| `codra-app` | The API, dashboard, and webhook receiver on port 3000 | + +The app waits for Postgres and Redis to report healthy and for `migrate` to +finish, so a first boot lands on a ready schema. Postgres and Redis are +published on `127.0.0.1` only, so deploying this file to a VPS does not expose +the database to the internet. + +Useful commands: + +```bash +docker compose logs -f codra-app # follow the app logs +docker compose ps # health of each service +docker compose down # stop (volumes are kept) +docker compose down -v # stop and delete the database +``` + +## Configuration + +Every value lives in `.env`; see `.env.docker.example` for the full list with +comments. The ones you must set before the app will boot: + +| Variable | Notes | +| --------------------------- | -------------------------------------------------- | +| `GITHUB_APP_ID` | From your GitHub App settings page | +| `GITHUB_APP_WEBHOOK_SECRET` | The webhook secret on that same page | +| `APP_PRIVATE_KEY` | The App private key, newlines written as `\n` | +| `GITHUB_CLIENT_ID` | OAuth client, for dashboard sign-in | +| `GITHUB_CLIENT_SECRET` | OAuth client secret | +| `DASHBOARD_ALLOWED_USERS` | Your GitHub username; nobody else can sign in | +| `LLM_CONFIG_ENCRYPTION_KEY` | `openssl rand -base64 48`, encrypts provider keys | +| `APP_URL` | Public URL; set with `AUTH_CALLBACK_URL` for a domain | + +`DATABASE_URL` and `REDIS_URL` already point at the bundled services. Point them +somewhere else to use a managed database or a hosted Redis. + +LLM provider API keys are not environment variables: add them from the dashboard +Settings page once you can sign in. + +## Running without Docker + +```bash +npm ci +npx vite build # builds the dashboard into dist/client +npm run dev --workspace=@codraoss/node-server +``` + +The server reads `.dev.vars` from the repository root, and expects Postgres and +Redis to be reachable at `DATABASE_URL` and `REDIS_URL`. + +## Current limitation + +Queued reviews are not processed yet. The webhook receiver, dashboard, and queue +producer all work, but the review runtime for Node is still being built +(issues [#106](https://github.com/devarshishimpi/codra/issues/106) and +[#107](https://github.com/devarshishimpi/codra/issues/107)), so jobs land in +Redis and wait there. Cloudflare Workers remains the deployment target that +completes reviews today. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..98a58266 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +# Full Codra stack for self-hosting: Postgres, Redis, and the Node server. +# +# cp .env.docker.example .env +# docker compose up -d +# +# Postgres and Redis are published on the loopback interface only, so a VPS +# running this file does not expose its database to the internet. + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-codra} + ports: + - '127.0.0.1:5432:5432' + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-codra}'] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ['redis-server', '--appendonly', 'yes'] + ports: + - '127.0.0.1:6379:6379' + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + # Applies packages/db/migrations against an empty database and exits. The app + # waits on it, so a fresh `docker compose up` boots against a ready schema. + migrate: + build: + context: . + dockerfile: apps/node/Dockerfile + image: codra-node:latest + restart: 'no' + command: ['node', 'packages/db/scripts/migrate.mjs'] + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/codra} + depends_on: + postgres: + condition: service_healthy + + codra-app: + build: + context: . + dockerfile: apps/node/Dockerfile + image: codra-node:latest + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/codra} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + PORT: '3000' + ports: + - '${PORT:-3000}:3000' + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://127.0.0.1:3000/healthz'] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + postgres-data: + redis-data: From d706f791428cc480dfe9bfe6c10c02d4a5ca6e50 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 22 Sep 2026 17:22:32 +0100 Subject: [PATCH 3/3] fix(node): address review findings on the Docker stack - Mark the runner's bundle as ESM. tsup emits ESM as index.js, which ran only because Node's module-syntax auto-detection covers it on current 20.19+ and 22.7+ images; a pinned older digest would fail to start. - Derive DATABASE_URL from POSTGRES_USER/PASSWORD/DB instead of hardcoding the credentials. Changing POSTGRES_PASSWORD alone previously left migrate and the app authenticating with the old password, and the app never started because it gates on migrate completing. - Stop documenting Redis as session storage: sessions use InMemorySessionStore, so a restart signs dashboard users out. Recorded as a known limitation. - Drop the telemetry block from the env example. Telemetry lives in apps/worker and is not in this image, so the opt-out variable did nothing. - Note that the bundled credentials are development defaults and that the published ports can be removed. --- .env.docker.example | 15 ++++++++------- apps/node/Dockerfile | 6 ++++++ apps/node/README.md | 19 ++++++++++++++----- docker-compose.yml | 4 ++-- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 0e42949f..3e1a3c0f 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -6,12 +6,17 @@ # Keep real secrets in .env only. It is gitignored and excluded from the image. # --- Database and queue --- -# Defaults point at the postgres and redis services in docker-compose.yml. +# These configure the bundled postgres service. Change POSTGRES_PASSWORD before +# deploying anywhere real; DATABASE_URL is built from these three values, so you +# do not have to keep a connection string in sync by hand. POSTGRES_USER="postgres" POSTGRES_PASSWORD="postgres" POSTGRES_DB="codra" -DATABASE_URL="postgres://postgres:postgres@postgres:5432/codra" -REDIS_URL="redis://redis:6379" + +# Set these two only to point at a database or Redis you host elsewhere. +# Left unset, they resolve to the bundled postgres and redis services. +# DATABASE_URL="postgres://user:password@host:5432/codra" +# REDIS_URL="redis://host:6379" # --- Application URLs --- # Set both to your public URL when deploying behind a domain. @@ -41,7 +46,3 @@ BOT_USERNAME="REPLACE_WITH_YOUR_BOT_USERNAME" # Encrypts dashboard-managed provider API keys at rest. Generate with: # openssl rand -base64 48 LLM_CONFIG_ENCRYPTION_KEY="REPLACE_WITH_A_LONG_RANDOM_ENCRYPTION_KEY" - -# --- Telemetry (optional) --- -# Anonymous aggregate usage stats are sent to https://codra.run/api/telemetry. -# TELEMETRY_DISABLED="true" diff --git a/apps/node/Dockerfile b/apps/node/Dockerfile index ba58cfae..46ab3937 100644 --- a/apps/node/Dockerfile +++ b/apps/node/Dockerfile @@ -62,6 +62,12 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 +# tsup emits ESM. Node 22 only runs it unflagged because of module syntax +# auto-detection; this marks the bundle explicitly so an older base image or a +# stricter loader cannot turn startup into "Cannot use import statement outside +# a module". +RUN printf '{"type":"module"}\n' > package.json + COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/apps/node/dist ./dist COPY --from=builder /app/dist/client ./dist/client diff --git a/apps/node/README.md b/apps/node/README.md index 5609afe5..629683d4 100644 --- a/apps/node/README.md +++ b/apps/node/README.md @@ -24,14 +24,19 @@ The dashboard is at . | Service | What it does | | ----------- | --------------------------------------------------------- | | `postgres` | Database, on a named volume so data survives restarts | -| `redis` | Session storage, config cache, and the review job queue | +| `redis` | Config cache and the review job queue | | `migrate` | Applies `packages/db/migrations`, then exits | | `codra-app` | The API, dashboard, and webhook receiver on port 3000 | The app waits for Postgres and Redis to report healthy and for `migrate` to -finish, so a first boot lands on a ready schema. Postgres and Redis are -published on `127.0.0.1` only, so deploying this file to a VPS does not expose -the database to the internet. +finish, so a first boot lands on a ready schema. + +Postgres and Redis are published on `127.0.0.1` only, so deploying this file to +a VPS does not expose them to the internet. They are still reachable by anything +else on the host, and they ship with development credentials, so change +`POSTGRES_PASSWORD` before you deploy and drop the `ports:` entries from +`docker-compose.yml` if you do not need to reach them from the host; the +services find each other over the compose network either way. Useful commands: @@ -75,7 +80,11 @@ npm run dev --workspace=@codraoss/node-server The server reads `.dev.vars` from the repository root, and expects Postgres and Redis to be reachable at `DATABASE_URL` and `REDIS_URL`. -## Current limitation +## Current limitations + +Dashboard sessions are held in memory, not in Redis, so restarting or +redeploying the container signs every dashboard user out. They sign back in +through GitHub; nothing else is lost. Queued reviews are not processed yet. The webhook receiver, dashboard, and queue producer all work, but the review runtime for Node is still being built diff --git a/docker-compose.yml b/docker-compose.yml index 98a58266..4893e87a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,7 @@ services: restart: 'no' command: ['node', 'packages/db/scripts/migrate.mjs'] environment: - DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/codra} + DATABASE_URL: ${DATABASE_URL:-postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-codra}} depends_on: postgres: condition: service_healthy @@ -62,7 +62,7 @@ services: env_file: - .env environment: - DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/codra} + DATABASE_URL: ${DATABASE_URL:-postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-codra}} REDIS_URL: ${REDIS_URL:-redis://redis:6379} PORT: '3000' ports: