diff --git a/apps/api/.env.example b/apps/api/.env.example index c4eecc4..2a1cd7e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,6 +1,7 @@ -# Copy to .env.local for development. Never commit real values. +# Copy to apps/api/.env.local for `bun run dev`, or to .env next to +# docker-compose.yml for Docker Compose. Never commit real values. -# PostgreSQL connection string (see compose.yaml for the local dev database) +# PostgreSQL connection string (see docker-compose.yml for the local dev database) DATABASE_URL=postgres://refined:refined@127.0.0.1:5432/refined # Auth.js session encryption secret, at least 32 characters @@ -33,3 +34,14 @@ STORAGE_DIR=./storage # Test database used by the integration test suite (optional; defaults shown) TEST_DATABASE_URL=postgres://refined:refined@127.0.0.1:5432/refined_test + +# --- Docker Compose only (read from .env next to docker-compose.yml) --- +# Postgres credentials, defaults shown. Postgres only reads them when it first +# creates the database, and Compose builds the containers' DATABASE_URL from +# them (the DATABASE_URL above is not used inside Compose). +# POSTGRES_USER=refined +# POSTGRES_PASSWORD=refined +# POSTGRES_DB=refined +# Host ports, if 5432 or 3000 are already taken. +# DB_PORT=5432 +# API_PORT=3000 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 0300e21..a4b0da5 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 -FROM oven/bun:1.4.2 AS build +# Base images are pinned by digest so a rebuild cannot silently change them. +FROM oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 AS build WORKDIR /repo # Workspace manifests first so dependency installation is cached. @@ -16,16 +17,18 @@ COPY packages/shared packages/shared COPY apps/api apps/api WORKDIR /repo/apps/api -RUN bun run build +# The server, plus the migration, project-init and GitHub-sync scripts bundled +# into single files that plain Node runs, so the runtime image needs neither +# Bun nor the source tree. +RUN bun run build && bun run build:scripts -# Migration runner: the same repo with dev tooling (drizzle-kit) available. -# Used as `docker compose run --rm migrate` before starting a new image. -FROM build AS migrate -WORKDIR /repo/apps/api -CMD ["bun", "run", "db:migrate"] +FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS runner -FROM node:24-alpine AS runner -WORKDIR /app +# The server never installs packages, so drop the package managers: less to +# ship, and fewer advisories to track. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ + /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ + /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -35,16 +38,19 @@ ENV STORAGE_DIR=/app/storage RUN mkdir -p /app/storage && chown node:node /app/storage -# Standalone output does not include public/ or static/; both are required for -# the UI (design CSS, fonts, emblem) and must be copied explicitly. -COPY --from=build --chown=node:node /repo/apps/api/.next/standalone/ ./ -COPY --from=build --chown=node:node /repo/apps/api/.next/static ./apps/api/.next/static -COPY --from=build --chown=node:node /repo/apps/api/public ./apps/api/public +# Standalone output does not include public/ or .next/static; the UI needs both +# (client bundles, stylesheets, images), so they are copied explicitly. +COPY --from=build --chown=node:node /repo/apps/api/.next/standalone/ /app/ +COPY --from=build --chown=node:node /repo/apps/api/.next/static /app/apps/api/.next/static +COPY --from=build --chown=node:node /repo/apps/api/public /app/apps/api/public +COPY --from=build --chown=node:node /repo/apps/api/drizzle /app/apps/api/drizzle +COPY --from=build --chown=node:node /repo/apps/api/dist/scripts /app/apps/api/scripts +WORKDIR /app/apps/api USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1 -CMD ["node", "apps/api/server.js"] +CMD ["node", "server.js"] diff --git a/apps/api/next.config.ts b/apps/api/next.config.ts index 3f0c1cd..dfce2f9 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -12,6 +12,12 @@ const securityHeaders = [ const nextConfig: NextConfig = { output: "standalone", + // No page uses next/image, so the image optimiser (sharp and its native + // libvips, about 19 MB) is left out of the production output. + images: { unoptimized: true }, + outputFileTracingExcludes: { + "*": ["../../node_modules/.bun/sharp@*/**", "../../node_modules/.bun/@img+*/**"], + }, async headers() { return [{ source: "/:path*", headers: securityHeaders }]; }, diff --git a/apps/api/package.json b/apps/api/package.json index 35743f4..b167d9c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,12 +5,13 @@ "scripts": { "dev": "next dev", "build": "next build", + "build:scripts": "bun build src/scripts/migrate.ts src/scripts/init-project.ts src/scripts/sync-github.ts --target=node --format=esm --external pg-native --outdir dist/scripts", "start": "next start", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", + "db:migrate": "bun run src/scripts/migrate.ts", "db:init": "bun run src/scripts/init-project.ts", "sync:github": "bun run src/scripts/sync-github.ts", "dev:session": "bun run src/scripts/dev-session.ts" diff --git a/apps/api/src/scripts/migrate.ts b/apps/api/src/scripts/migrate.ts new file mode 100644 index 0000000..7744626 --- /dev/null +++ b/apps/api/src/scripts/migrate.ts @@ -0,0 +1,31 @@ +import { config as loadEnv } from "dotenv"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; + +loadEnv({ path: ".env.local" }); +loadEnv(); + +// Applies the SQL migrations in ./drizzle, relative to the working directory: +// apps/api locally, and the app directory in the production image. Drizzle +// records applied migrations, so rerunning is safe. +async function main(): Promise { + const connectionString = process.env.DATABASE_URL; + if (connectionString === undefined || connectionString.length === 0) { + throw new Error("DATABASE_URL is required"); + } + const pool = new Pool({ connectionString, max: 1 }); + try { + await migrate(drizzle(pool), { migrationsFolder: "drizzle" }); + } finally { + await pool.end(); + } + console.log("Migrations applied."); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Migration failed:", error); + process.exit(1); + }); diff --git a/docker-compose.yml b/docker-compose.yml index 526eae9..a769c4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,50 +1,67 @@ +# All settings come from one file: `.env` next to this file (copy +# apps/api/.env.example). Compose reads it for its own settings and passes it +# to the app and migrate containers. apps/api/.env.local is only for +# `bun run dev` and is not read here. The file is optional so that +# `bun run setup` can start the database without it. +x-app-env: &app-env + env_file: + - path: .env + required: false + services: db: image: postgres:17-alpine - # Local-development credentials only. Production deployments must replace - # these values through their secret manager and should not publish Postgres. + # The defaults are for local development. Set POSTGRES_PASSWORD (URL-safe, + # e.g. `openssl rand -hex 24`) in `.env` before the first start of a + # deployment: Postgres only reads these when it creates the database. environment: - POSTGRES_USER: refined - POSTGRES_PASSWORD: refined - POSTGRES_DB: refined + POSTGRES_USER: ${POSTGRES_USER:-refined} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-refined} + POSTGRES_DB: ${POSTGRES_DB:-refined} + # Override the host ports with DB_PORT / API_PORT when they are taken. ports: - - "127.0.0.1:5432:5432" + - "127.0.0.1:${DB_PORT:-5432}:5432" volumes: - gov-portal-pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U refined -d refined"] + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] interval: 5s timeout: 5s retries: 10 api: + image: devnepal:local build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped - env_file: - - apps/api/.env.local + <<: *app-env environment: - DATABASE_URL: postgres://refined:refined@db:5432/refined + DATABASE_URL: postgres://${POSTGRES_USER:-refined}:${POSTGRES_PASSWORD:-refined}@db:5432/${POSTGRES_DB:-refined} STORAGE_DIR: /app/storage ports: - - "127.0.0.1:3000:3000" + - "127.0.0.1:${API_PORT:-3000}:3000" volumes: - gov-portal-avatars:/app/storage depends_on: migrate: condition: service_completed_successfully - # Gate API startup on a successful schema migration. This is safe to rerun: - # Drizzle records applied migrations in the database. + # Gate API startup on a successful schema migration, using the same image. + # This is safe to rerun: Drizzle records applied migrations in the database. + # The image also carries the project-init and GitHub-sync scripts: + # docker compose run --rm migrate node scripts/init-project.js + # docker compose run --rm migrate node scripts/sync-github.js migrate: + image: devnepal:local build: context: . dockerfile: apps/api/Dockerfile - target: migrate + command: ["node", "scripts/migrate.js"] restart: "no" + <<: *app-env environment: - DATABASE_URL: postgres://refined:refined@db:5432/refined + DATABASE_URL: postgres://${POSTGRES_USER:-refined}:${POSTGRES_PASSWORD:-refined}@db:5432/${POSTGRES_DB:-refined} depends_on: db: condition: service_healthy diff --git a/docs/deployment.md b/docs/deployment.md index 72d24b1..c24e609 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,13 +13,61 @@ The image is built by `bun run build` in the container, which has **no `.env` files** — verified to build without environment variables. All configuration is read at runtime from the process environment. +With Docker Compose, every setting goes in one `.env` file next to +`docker-compose.yml`; hosting platforms that deploy a Compose file usually write +this file from their settings screen. Compose does not read +`apps/api/.env.local`, which is only for `bun run dev`. Copy +`apps/api/.env.example` to `.env`; its last section lists the settings only +Compose uses (`POSTGRES_*`, `DB_PORT`, `API_PORT`). Set `POSTGRES_PASSWORD` (URL-safe, +for example `openssl rand -hex 24`) before the first start: Postgres only reads +it when it creates the database, and the app's `DATABASE_URL` is built from +it. + +## Deploying on Dokploy + +`prod-docker-compose.yaml` is the production stack for +[Dokploy](https://docs.dokploy.com/docs/core/docker-compose): a one-shot +migration and the app, with nothing published on the host. The database is a +separate Dokploy-managed Postgres service in the same project. + +1. **Create the database.** In the Dokploy project, add a **Postgres** service. + Leave its external port unset, so it is reachable only inside the server. +2. **Create the app service.** Add a **Docker Compose** service from this + repository, branch `main`, with the compose path set to + `./prod-docker-compose.yaml`. +3. **Set the environment** (Environment tab of the Compose service). Dokploy + writes it to `.env` next to the compose file, and the stack loads that file. + Required: + - `DATABASE_URL`: the Postgres service's **Internal Connection URL** + - `AUTH_URL`: the public origin, for example `https://devnepal.gov.np` + - `AUTH_SECRET`: `openssl rand -base64 48` + - `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`: the production GitHub OAuth App + + Optional: `ADMIN_GITHUB_IDS`, `GITHUB_TOKEN`, `GITHUB_PROJECT_REPOSITORY`. + Compose refuses to deploy without `DATABASE_URL` or `AUTH_URL`, naming the + missing one. +4. **Add the domain** (Domains tab): service `app`, container port `3000`, + HTTPS on. Point the domain's DNS at the server, and add + `https:///api/auth/callback/github` to the OAuth App's callback URLs. +5. **Deploy.** The migration runs first and the app starts once it succeeds. +6. **Load the project once.** In the `app` container's terminal, run + `node scripts/init-project.js`. To refresh issues on a schedule, add + `node scripts/sync-github.js` to the `app` service under Schedules. + +Back up the database from the Postgres service's Backups tab, and the `avatars` +volume with Dokploy's volume backups. The server builds the image on each +deploy, which needs a few GB of free memory. Building in CI and deploying from +a registry image avoids that, as Dokploy recommends. + ## Before you deploy — checklist 1. **Postgres 17** reachable from the app. Set `DATABASE_URL`. 2. **Migrations run before the app starts.** With Compose, the `api` service - waits for the one-shot `migrate` service to finish successfully. Outside - Compose, run `bun run db:migrate` against the target database first. The app - process itself never migrates, so a crash-looping app cannot half-migrate a + waits for the one-shot `migrate` service, which runs the same image, to + finish successfully. Outside Compose, run `node scripts/migrate.js` in the + image, or `bun run db:migrate` from a checkout, against the target database + first. The app process itself never migrates, so a crash-looping app cannot + half-migrate a database. 3. **`AUTH_SECRET`** ≥ 32 characters, generated fresh per environment (`openssl rand -base64 48`). Changing it signs everyone out. @@ -48,7 +96,7 @@ read at runtime from the process environment. | Problem | Why it happens | Fix (already in repo unless noted) | |---|---|---| | UI unstyled, no emblem/fonts in the image | Next standalone does not copy `public/` or `.next/static` | Dockerfile copies both explicitly | -| Migrations unavailable in the runtime image | `drizzle-kit` is a dev dependency and drizzle-orm is bundled into the build | `migrate` build target + `docker compose run --rm migrate` | +| Migrations unavailable in the runtime image | `drizzle-kit` is a dev dependency and drizzle-orm is bundled into the build | The migration, project-init and GitHub-sync scripts are bundled into `scripts/` in the image and run with `node`; the SQL is in `drizzle/` | | App listens only on localhost | Standalone defaults bind `HOSTNAME` | `HOSTNAME=0.0.0.0` in the image | | Browser PATCH rejected with 403 after deployment | Origin guard must trust the proxy-resolved own origin | Request-origin + `x-forwarded-*` support in `assertSameOrigin` | | Everyone shares one rate-limit bucket behind a tunnel | Proxy IPs hide the client | `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip` order in `clientIp` | diff --git a/prod-docker-compose.yaml b/prod-docker-compose.yaml new file mode 100644 index 0000000..c66c3cf --- /dev/null +++ b/prod-docker-compose.yaml @@ -0,0 +1,65 @@ +# Production stack for Dokploy (https://docs.dokploy.com/docs/core/docker-compose). +# +# Database: a Dokploy-managed Postgres service in the same project, which gets +# Dokploy's database backups. Set DATABASE_URL to that service's Internal +# Connection URL; the app and migrate reach it over dokploy-network. +# +# Settings: Dokploy writes the Environment tab to `.env` next to this file but +# does not inject it, so both services load it with `env_file`. Every setting is +# listed in apps/api/.env.example; Compose refuses to start without +# DATABASE_URL or AUTH_URL. +# +# Routing: add the domain in Dokploy's Domains tab for service `app`, port 3000. +# Nothing is published on the host. +# +# Data: avatars live in a named volume, so Dokploy's volume backups can include +# it. Do not bind mount paths from the repository; Dokploy clears it on every +# deployment. + +x-database-url: &database-url ${DATABASE_URL:?Set DATABASE_URL to the Dokploy Postgres service's Internal Connection URL} + +services: + # Applies migrations, then exits; the app starts only after it succeeds. + migrate: + build: + context: . + dockerfile: apps/api/Dockerfile + image: devnepal:production + command: ["node", "scripts/migrate.js"] + restart: "no" + env_file: + - .env + environment: + DATABASE_URL: *database-url + networks: + - dokploy-network + + app: + build: + context: . + dockerfile: apps/api/Dockerfile + image: devnepal:production + restart: always + env_file: + - .env + environment: + DATABASE_URL: *database-url + STORAGE_DIR: /app/storage + # The GitHub callback URL is built from this; without it sign-in breaks. + AUTH_URL: ${AUTH_URL:?Set AUTH_URL to the public origin, e.g. https://devnepal.gov.np} + expose: + - "3000" + volumes: + - avatars:/app/storage + networks: + - dokploy-network + depends_on: + migrate: + condition: service_completed_successfully + +networks: + dokploy-network: + external: true + +volumes: + avatars: