From 22d7c6f2001464bfabbdefcc560f2ffb81886c3a Mon Sep 17 00:00:00 2001 From: voidash Date: Sun, 27 Sep 2026 22:51:27 +0545 Subject: [PATCH 1/7] chore: make compose ports configurable The host ports for Postgres and the app were fixed at 5432 and 3000. They can now be overridden with DB_PORT and API_PORT; the defaults are unchanged. Also document that the migrate image runs one-off tasks such as db:init, and fix a Dockerfile comment that still mentioned the removed design CSS and fonts. Signed-off-by: voidash --- apps/api/Dockerfile | 4 ++-- docker-compose.yml | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 0300e21..2ec84d0 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -35,8 +35,8 @@ 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. +# 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/ ./ 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 diff --git a/docker-compose.yml b/docker-compose.yml index 526eae9..0507427 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,9 @@ services: POSTGRES_USER: refined POSTGRES_PASSWORD: refined 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: @@ -28,7 +29,7 @@ services: DATABASE_URL: postgres://refined:refined@db:5432/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: @@ -36,7 +37,9 @@ services: condition: service_completed_successfully # Gate API startup on a successful schema migration. This is safe to rerun: - # Drizzle records applied migrations in the database. + # Drizzle records applied migrations in the database. The image also carries + # the repository scripts, so it runs one-off tasks too: + # docker compose run --rm migrate bun run db:init migrate: build: context: . From e0e29d35eb8f1f2ca126b54abedbca03c2383559 Mon Sep 17 00:00:00 2001 From: voidash Date: Sun, 27 Sep 2026 23:37:35 +0545 Subject: [PATCH 2/7] chore: ship one lean runtime image Migrations ran from a separate image that was the whole build stage, dev dependencies included (2.07 GB), as root. The migration, project-init and GitHub-sync scripts are now bundled into single files that Node runs from the app image, so the migrate service uses the same image and the separate one is gone. db:migrate uses the same Drizzle migrator, which shares its history table with drizzle-kit. The runtime image also leaves out the unused image optimiser (sharp and libvips, about 19 MB; no page uses next/image) and the package managers, and both base images are pinned by digest. Signed-off-by: voidash --- apps/api/Dockerfile | 32 +++++++++++++++++++------------- apps/api/next.config.ts | 6 ++++++ apps/api/package.json | 3 ++- apps/api/src/scripts/migrate.ts | 31 +++++++++++++++++++++++++++++++ docker-compose.yml | 15 ++++++++++----- docs/deployment.md | 8 +++++--- 6 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/scripts/migrate.ts diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 2ec84d0..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 @@ -37,14 +40,17 @@ RUN mkdir -p /app/storage && chown node:node /app/storage # 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/ ./ -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 +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 0507427..b94a438 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: retries: 10 api: + image: devnepal:local build: context: . dockerfile: apps/api/Dockerfile @@ -36,16 +37,20 @@ services: 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. The image also carries - # the repository scripts, so it runs one-off tasks too: - # docker compose run --rm migrate bun run db:init + # 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" + env_file: + - apps/api/.env.local environment: DATABASE_URL: postgres://refined:refined@db:5432/refined depends_on: diff --git a/docs/deployment.md b/docs/deployment.md index 72d24b1..959c986 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -17,8 +17,10 @@ read at runtime from the process environment. 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 + 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 @@ -48,7 +50,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` | From b73265983ec693366270a3903939741f73527922 Mon Sep 17 00:00:00 2001 From: voidash Date: Thu, 1 Oct 2026 00:31:27 +0545 Subject: [PATCH 3/7] fix: take compose settings from .env as well as .env.local Compose required apps/api/.env.local and passed nothing else into the containers, so a deployment that supplies settings the usual way, in a .env next to docker-compose.yml, could not start: Compose refused to run without the file, and once it existed the app still received only DATABASE_URL and STORAGE_DIR. Both files are now optional env files for the app and migrate services; .env.local wins where both set a value. The Postgres credentials can also be set from .env (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB), with the previous values as defaults, and DATABASE_URL is built from them. Signed-off-by: voidash --- apps/api/.env.example | 2 +- docker-compose.yml | 33 +++++++++++++++++++++------------ docs/deployment.md | 13 +++++++++++-- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index c4eecc4..c5605ee 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,6 +1,6 @@ # Copy to .env.local for development. 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 diff --git a/docker-compose.yml b/docker-compose.yml index b94a438..fdde877 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,30 @@ +# Settings come from two optional files: `.env` next to this file (what hosting +# platforms write) and `apps/api/.env.local` (what `bun run setup` writes). If +# both set a value, `.env.local` wins. See apps/api/.env.example for the list. +x-app-env: &app-env + env_file: + - path: .env + required: false + - path: apps/api/.env.local + 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:${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 @@ -24,10 +35,9 @@ services: 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:${API_PORT:-3000}:3000" @@ -49,10 +59,9 @@ services: dockerfile: apps/api/Dockerfile command: ["node", "scripts/migrate.js"] restart: "no" - 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} depends_on: db: condition: service_healthy diff --git a/docs/deployment.md b/docs/deployment.md index 959c986..5f23baf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,6 +13,15 @@ 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, put the settings in a `.env` file next to +`docker-compose.yml`; hosting platforms that deploy a Compose file usually write +this file from their settings screen. Compose also reads `apps/api/.env.local`, +the file local development uses, and that one wins where both set a value. See +`apps/api/.env.example` for every setting. 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. + ## Before you deploy — checklist 1. **Postgres 17** reachable from the app. Set `DATABASE_URL`. @@ -20,8 +29,8 @@ read at runtime from the process environment. 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 + 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. From ddf54a7783f5019fb30a7a39fd9c7590a7fd0f91 Mon Sep 17 00:00:00 2001 From: voidash Date: Sat, 3 Oct 2026 14:38:02 +0545 Subject: [PATCH 4/7] chore: add a production compose file for Dokploy prod-docker-compose.yaml runs Postgres, the one-shot migration and the app the way Dokploy expects: settings load from the .env Dokploy writes, the app only exposes port 3000 and joins dokploy-network for Traefik, Postgres stays on the stack's own network, data is in named volumes, and no service sets container_name. Compose refuses to deploy without AUTH_URL or POSTGRES_PASSWORD, and there is no default database password. The deployment guide gets step-by-step Dokploy instructions. Signed-off-by: voidash --- docs/deployment.md | 35 ++++++++++++++++++ prod-docker-compose.yaml | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 prod-docker-compose.yaml diff --git a/docs/deployment.md b/docs/deployment.md index 5f23baf..06ba0bc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -22,6 +22,41 @@ 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): Postgres, a +one-shot migration, and the app, with nothing published on the host. + +1. **Create the service.** In a Dokploy project, add a **Docker Compose** + service from this repository, branch `main`, with the compose path set to + `./prod-docker-compose.yaml`. +2. **Set the environment** (Environment tab). Dokploy writes it to `.env` next + to the compose file, and the stack loads that file. Required: + - `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 + - `POSTGRES_PASSWORD`: URL-safe, for example `openssl rand -hex 24`. Set it + before the first deploy; Postgres only reads it when it creates the + database. + + Optional: `ADMIN_GITHUB_IDS`, `GITHUB_TOKEN`, `GITHUB_PROJECT_REPOSITORY`, + `POSTGRES_USER` and `POSTGRES_DB` (both default to `devnepal`). Compose + refuses to deploy without `AUTH_URL` or `POSTGRES_PASSWORD`, naming the + missing one. +3. **Add the domain** (Domains tab): service `app`, container port `3000`, + HTTPS on. Point the domain's DNS at the server, and set the OAuth App's + callback URL to `https:///api/auth/callback/github`. +4. **Deploy.** The migration runs first and the app starts once it succeeds. +5. **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. + +The database and avatars live in the named volumes `pgdata` and `avatars`; add +them to 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`. diff --git a/prod-docker-compose.yaml b/prod-docker-compose.yaml new file mode 100644 index 0000000..882a7e3 --- /dev/null +++ b/prod-docker-compose.yaml @@ -0,0 +1,78 @@ +# Production stack for Dokploy (https://docs.dokploy.com/docs/core/docker-compose). +# +# Settings: Dokploy writes the Environment tab to `.env` next to this file but +# does not inject it, so the app and migrate services load it with `env_file`. +# Every setting is listed in apps/api/.env.example; production also needs +# POSTGRES_PASSWORD and AUTH_URL, and Compose refuses to start without them. +# +# Routing: add the domain in Dokploy's Domains tab for service `app`, port 3000. +# Nothing is published on the host; Postgres is reachable only inside the stack. +# +# Data: named volumes, so Dokploy's volume backups can include them. Do not bind +# mount paths from the repository; Dokploy clears it on every deployment. + +x-database-url: &database-url postgres://${POSTGRES_USER:-devnepal}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD (URL-safe, e.g. openssl rand -hex 24)}@db:5432/${POSTGRES_DB:-devnepal} + +services: + db: + image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-devnepal} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD (URL-safe, e.g. openssl rand -hex 24)} + POSTGRES_DB: ${POSTGRES_DB:-devnepal} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 5s + timeout: 5s + retries: 10 + + # 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 + depends_on: + db: + condition: service_healthy + + 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: + - default + - dokploy-network + depends_on: + migrate: + condition: service_completed_successfully + +networks: + dokploy-network: + external: true + +volumes: + pgdata: + avatars: From 78a7666a48d4efbbbc9c812cb9767bccb47465b8 Mon Sep 17 00:00:00 2001 From: voidash Date: Sun, 4 Oct 2026 10:21:08 +0545 Subject: [PATCH 5/7] docs: say which compose settings are read from .env only Compose substitutes ${...} only from the shell and the .env next to docker-compose.yml, so POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DB_PORT and API_PORT set in apps/api/.env.local were silently ignored. The compose header implied otherwise. Say which settings come from where, add a root .env.example for the Compose-level ones, and note it in the deployment guide. Signed-off-by: voidash --- .env.example | 15 +++++++++++++++ docker-compose.yml | 10 +++++++--- docs/deployment.md | 4 +++- 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1c6e05 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Settings Docker Compose itself uses. Copy to `.env` next to docker-compose.yml. +# Compose reads these only from this `.env` or the shell, never from +# apps/api/.env.local. App settings (AUTH_*, GITHUB_*, ...) are listed in +# apps/api/.env.example; they can go in apps/api/.env.local or in this `.env`. + +# Postgres credentials. Postgres only reads them when it first creates the +# database. Compose builds the app's DATABASE_URL from them; if you change them +# for local development, change DATABASE_URL in apps/api/.env.local to match. +POSTGRES_USER=refined +POSTGRES_PASSWORD=refined +POSTGRES_DB=refined + +# Host ports for docker-compose.yml, if 5432 or 3000 are already taken. +DB_PORT=5432 +API_PORT=3000 diff --git a/docker-compose.yml b/docker-compose.yml index fdde877..5e2ced6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,10 @@ -# Settings come from two optional files: `.env` next to this file (what hosting -# platforms write) and `apps/api/.env.local` (what `bun run setup` writes). If -# both set a value, `.env.local` wins. See apps/api/.env.example for the list. +# Two kinds of settings, read from different places: +# - Compose's own settings (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, +# DB_PORT, API_PORT) come only from the shell or from `.env` next to this +# file. Compose never reads apps/api/.env.local for them. See .env.example. +# - App settings reach the app and migrate containers from `.env` (what hosting +# platforms write) and from apps/api/.env.local (what `bun run setup` +# writes); .env.local wins where both set a value. See apps/api/.env.example. x-app-env: &app-env env_file: - path: .env diff --git a/docs/deployment.md b/docs/deployment.md index 06ba0bc..0b3e641 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -17,7 +17,9 @@ With Docker Compose, put the settings in a `.env` file next to `docker-compose.yml`; hosting platforms that deploy a Compose file usually write this file from their settings screen. Compose also reads `apps/api/.env.local`, the file local development uses, and that one wins where both set a value. See -`apps/api/.env.example` for every setting. Set `POSTGRES_PASSWORD` (URL-safe, +`apps/api/.env.example` for every setting. The Compose-level settings in the +root `.env.example` (`POSTGRES_*`, `DB_PORT`, `API_PORT`) are read only from +`.env` or the shell, never from `.env.local`. 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. From f5c6d88fa67143973ab1936a2a895c85bc730194 Mon Sep 17 00:00:00 2001 From: voidash Date: Sun, 4 Oct 2026 10:48:19 +0545 Subject: [PATCH 6/7] fix: read every compose setting from .env only Compose read its own settings (POSTGRES_*, DB_PORT, API_PORT) only from .env, while the containers also took apps/api/.env.local and let it win, so some settings could be overridden from .env.local and others not. Compose now reads only .env, for both; apps/api/.env.local is for 'bun run dev' alone. The Compose-only settings move into a section of apps/api/.env.example, replacing the root .env.example. Signed-off-by: voidash --- .env.example | 15 --------------- apps/api/.env.example | 14 +++++++++++++- docker-compose.yml | 14 +++++--------- docs/deployment.md | 11 +++++------ 4 files changed, 23 insertions(+), 31 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index b1c6e05..0000000 --- a/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Settings Docker Compose itself uses. Copy to `.env` next to docker-compose.yml. -# Compose reads these only from this `.env` or the shell, never from -# apps/api/.env.local. App settings (AUTH_*, GITHUB_*, ...) are listed in -# apps/api/.env.example; they can go in apps/api/.env.local or in this `.env`. - -# Postgres credentials. Postgres only reads them when it first creates the -# database. Compose builds the app's DATABASE_URL from them; if you change them -# for local development, change DATABASE_URL in apps/api/.env.local to match. -POSTGRES_USER=refined -POSTGRES_PASSWORD=refined -POSTGRES_DB=refined - -# Host ports for docker-compose.yml, if 5432 or 3000 are already taken. -DB_PORT=5432 -API_PORT=3000 diff --git a/apps/api/.env.example b/apps/api/.env.example index c5605ee..2a1cd7e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,4 +1,5 @@ -# 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 docker-compose.yml for the local dev database) DATABASE_URL=postgres://refined:refined@127.0.0.1:5432/refined @@ -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/docker-compose.yml b/docker-compose.yml index 5e2ced6..a769c4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,12 @@ -# Two kinds of settings, read from different places: -# - Compose's own settings (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, -# DB_PORT, API_PORT) come only from the shell or from `.env` next to this -# file. Compose never reads apps/api/.env.local for them. See .env.example. -# - App settings reach the app and migrate containers from `.env` (what hosting -# platforms write) and from apps/api/.env.local (what `bun run setup` -# writes); .env.local wins where both set a value. See apps/api/.env.example. +# 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 - - path: apps/api/.env.local - required: false services: db: diff --git a/docs/deployment.md b/docs/deployment.md index 0b3e641..ba2665d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,13 +13,12 @@ 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, put the settings in a `.env` file next to +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 also reads `apps/api/.env.local`, -the file local development uses, and that one wins where both set a value. See -`apps/api/.env.example` for every setting. The Compose-level settings in the -root `.env.example` (`POSTGRES_*`, `DB_PORT`, `API_PORT`) are read only from -`.env` or the shell, never from `.env.local`. Set `POSTGRES_PASSWORD` (URL-safe, +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. From 1eefd7efa2fd8a5df976f03b12fb32cceb0d49b9 Mon Sep 17 00:00:00 2001 From: voidash Date: Sun, 4 Oct 2026 12:05:46 +0545 Subject: [PATCH 7/7] chore: use the Dokploy-managed Postgres in production The Dokploy project already runs a managed Postgres service, which gets Dokploy's database backups. prod-docker-compose.yaml no longer bundles its own database: the app and migrate read DATABASE_URL (the service's Internal Connection URL) and reach it over dokploy-network. Compose refuses to deploy without DATABASE_URL or AUTH_URL. The Dokploy steps in the deployment guide are updated to match. Signed-off-by: voidash --- docs/deployment.md | 45 ++++++++++++++++++++-------------------- prod-docker-compose.yaml | 41 +++++++++++++----------------------- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index ba2665d..c24e609 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -26,37 +26,38 @@ it. ## Deploying on Dokploy `prod-docker-compose.yaml` is the production stack for -[Dokploy](https://docs.dokploy.com/docs/core/docker-compose): Postgres, a -one-shot migration, and the app, with nothing published on the host. - -1. **Create the service.** In a Dokploy project, add a **Docker Compose** - service from this repository, branch `main`, with the compose path set to +[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`. -2. **Set the environment** (Environment tab). Dokploy writes it to `.env` next - to the compose file, and the stack loads that file. Required: +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 - - `POSTGRES_PASSWORD`: URL-safe, for example `openssl rand -hex 24`. Set it - before the first deploy; Postgres only reads it when it creates the - database. - Optional: `ADMIN_GITHUB_IDS`, `GITHUB_TOKEN`, `GITHUB_PROJECT_REPOSITORY`, - `POSTGRES_USER` and `POSTGRES_DB` (both default to `devnepal`). Compose - refuses to deploy without `AUTH_URL` or `POSTGRES_PASSWORD`, naming the + Optional: `ADMIN_GITHUB_IDS`, `GITHUB_TOKEN`, `GITHUB_PROJECT_REPOSITORY`. + Compose refuses to deploy without `DATABASE_URL` or `AUTH_URL`, naming the missing one. -3. **Add the domain** (Domains tab): service `app`, container port `3000`, - HTTPS on. Point the domain's DNS at the server, and set the OAuth App's - callback URL to `https:///api/auth/callback/github`. -4. **Deploy.** The migration runs first and the app starts once it succeeds. -5. **Load the project once.** In the `app` container's terminal, run +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. -The database and avatars live in the named volumes `pgdata` and `avatars`; add -them to 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. +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 diff --git a/prod-docker-compose.yaml b/prod-docker-compose.yaml index 882a7e3..c66c3cf 100644 --- a/prod-docker-compose.yaml +++ b/prod-docker-compose.yaml @@ -1,34 +1,24 @@ # 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 the app and migrate services load it with `env_file`. -# Every setting is listed in apps/api/.env.example; production also needs -# POSTGRES_PASSWORD and AUTH_URL, and Compose refuses to start without them. +# 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; Postgres is reachable only inside the stack. +# Nothing is published on the host. # -# Data: named volumes, so Dokploy's volume backups can include them. Do not bind -# mount paths from the repository; Dokploy clears it on every deployment. +# 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 postgres://${POSTGRES_USER:-devnepal}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD (URL-safe, e.g. openssl rand -hex 24)}@db:5432/${POSTGRES_DB:-devnepal} +x-database-url: &database-url ${DATABASE_URL:?Set DATABASE_URL to the Dokploy Postgres service's Internal Connection URL} services: - db: - image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24 - restart: always - environment: - POSTGRES_USER: ${POSTGRES_USER:-devnepal} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD (URL-safe, e.g. openssl rand -hex 24)} - POSTGRES_DB: ${POSTGRES_DB:-devnepal} - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] - interval: 5s - timeout: 5s - retries: 10 - # Applies migrations, then exits; the app starts only after it succeeds. migrate: build: @@ -41,9 +31,8 @@ services: - .env environment: DATABASE_URL: *database-url - depends_on: - db: - condition: service_healthy + networks: + - dokploy-network app: build: @@ -63,7 +52,6 @@ services: volumes: - avatars:/app/storage networks: - - default - dokploy-network depends_on: migrate: @@ -74,5 +62,4 @@ networks: external: true volumes: - pgdata: avatars: