Skip to content
Open
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 21 additions & 15 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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"]
6 changes: 6 additions & 0 deletions apps/api/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
},
Expand Down
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/scripts/migrate.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
51 changes: 36 additions & 15 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,50 +1,71 @@
# 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
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}
Comment thread
voidash marked this conversation as resolved.
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
Expand Down
56 changes: 52 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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. 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.

## 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://<domain>/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`.
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.
Expand Down Expand Up @@ -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` |
Expand Down
78 changes: 78 additions & 0 deletions prod-docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
Loading