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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Dockerignore for builds whose context is the repository root (the frontend
# image, issue #1335). The backend image uses backend/.dockerignore instead.
# Keeping the context lean makes builds faster and avoids leaking local state.

# Version control / editor / CI
.git
.gitignore
.gitattributes
.github
.husky
.vscode
.idea

# Dependencies and build output — reinstalled/rebuilt inside the image
node_modules
**/node_modules
frontend/.next
frontend/out
frontend/coverage
backend/dist
backend/coverage
backend/src/generated

# Tests and docs are not needed to build the app image
**/__tests__/**
**/*.test.*
**/*.spec.*
e2e
docs
contracts

# Secrets / local state
.env
.env.*
*.log
npm-debug.log*
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,13 @@ jobs:
run: |
docker compose up -d postgres
sleep 10
docker compose run --rm -e DATABASE_URL=postgresql://flowfi:flowfi_dev_password@postgres:5432/flowfi backend npx -y prisma db push --accept-data-loss
# The production image runs as the non-root `node` user and already
# ships the generated Prisma client (built in the builder stage and
# copied to dist/generated). `db push` otherwise triggers `prisma
# generate`, which needs to write into the root-owned node_modules
# tree and fails with "Can't write to /app/node_modules/prisma".
# Skip it: the client is already present, so only the schema push runs.
docker compose run --rm -e DATABASE_URL=postgresql://flowfi:flowfi_dev_password@postgres:5432/flowfi backend npx -y prisma db push --accept-data-loss --skip-generate
docker compose up -d backend
# Poll /health instead of a fixed sleep so cold-boot / Prisma-warmup
# latency does not flake the Backend Docker Image CI.
Expand Down
55 changes: 44 additions & 11 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# ============================================================================
# Stage 1 — builder
# Full Node toolchain (devDependencies incl. tsc + prisma CLI) compiles the
# TypeScript and generates the Prisma client. Discarded from the final image.
# ============================================================================
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder

WORKDIR /app
Expand All @@ -11,14 +16,18 @@ WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma

RUN npm install
RUN npm install --no-audit --no-fund

COPY tsconfig.json ./
COPY prisma.config.ts ./
COPY src ./src

RUN npm run build

# ============================================================================
# Stage 2 — production runner
# Minimal Alpine base, production-only dependencies, non-root `node` user.
# ============================================================================
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS runner

WORKDIR /app
Expand All @@ -30,23 +39,47 @@ ENV NODE_ENV=production
# the dependency install. --ignore-scripts skips the @prisma/client
# postinstall (we already COPY the generated client from the builder
# below), so the runner never needs the schema to install dependencies.
# The schema is then copied AFTER install so it is on disk for the
# `npx prisma db push` step the workflow runs against this container,
# while keeping the install layer cached whenever prisma/ is unchanged.
# The npm cache is purged in the same layer so it never ships in the image.
COPY package*.json ./
RUN npm install --omit=dev --ignore-scripts
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund \
&& npm cache clean --force

# `--ignore-scripts` also skips the @prisma/engines postinstall, so the
# Prisma schema-engine binary is absent from the image. `prisma db push`
# (run by the CI health-check step and by anyone applying migrations in the
# container) would then try to download it at runtime into the root-owned
# node_modules tree, which the non-root `node` user cannot write to:
# Error: Can't write to /app/node_modules/@prisma/engines
# Run the engines postinstall explicitly here, as root, while the layer is
# being built so the binary ships in the image and the runtime user only ever
# reads it. The @prisma/client postinstall stays skipped (its generated client
# is copied from the builder), so no schema is required at this point.
RUN node /app/node_modules/@prisma/engines/scripts/postinstall.js

# Copy the prisma schema into the runner. The boot-and-check-health step
# in .github/workflows/ci.yml runs `npx prisma db push` against this
# container; without prisma/schema.prisma on disk, that command errors
# out and the 60-iteration /health poll times the job out.
# out and the 60-iteration /health poll times the job out. That step passes
# `--skip-generate` because the client is already baked into dist/generated
# above — regenerating would need write access to the root-owned
# node_modules tree, which the non-root `node` user deliberately does not have.
COPY prisma ./prisma

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/generated ./dist/generated
COPY --from=builder /app/prisma ./prisma
COPY prisma.config.ts ./
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/src/generated ./dist/generated
COPY --chown=node:node prisma.config.ts ./

# Drop root privileges for the running process. The `node` user (uid 1000)
# ships with the node:20-alpine base image — no extra user needs creating.
USER node

EXPOSE 3001

CMD ["npm", "start"]
# Native healthcheck so Docker Compose / Kubernetes can gate traffic on
# readiness instead of only checking that the process is running.
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=5 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3001/health || exit 1

# Exec node directly instead of `npm start` so the server runs as PID 1 and
# receives SIGTERM/SIGINT for the graceful-shutdown handler in src/index.ts.
CMD ["node", "dist/index.js"]
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,31 @@ services:
start_period: 30s
restart: unless-stopped

frontend:
build:
context: .
dockerfile: frontend/Dockerfile
args:
# NEXT_PUBLIC_* are inlined into the client bundle at build time. The
# browser talks to the backend over the published host port, so use
# localhost here rather than the in-network service name.
NEXT_PUBLIC_API_URL: http://localhost:3001/v1
container_name: flowfi-frontend
environment:
NODE_ENV: production
PORT: 3000
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped

volumes:
postgres_data:
69 changes: 69 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ============================================================================
# Stage 1 — builder
# Installs the npm workspace from the repo root, then runs `next build`, which
# emits a self-contained server bundle (`output: "standalone"` in
# frontend/next.config.ts) instead of the full `.next` tree + node_modules.
# ============================================================================
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder

WORKDIR /app

# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so
# they must be passed as build args (runtime env has no effect on them).
# Defaults target the local Docker Compose stack; override with `--build-arg`
# for a real deployment.
ARG NEXT_PUBLIC_API_URL=http://localhost:3001/v1
ARG NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
ARG NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
ARG NEXT_PUBLIC_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_STELLAR_NETWORK=$NEXT_PUBLIC_STELLAR_NETWORK \
NEXT_PUBLIC_SOROBAN_RPC_URL=$NEXT_PUBLIC_SOROBAN_RPC_URL \
NEXT_PUBLIC_NETWORK_PASSPHRASE=$NEXT_PUBLIC_NETWORK_PASSPHRASE

# Copy the workspace manifests first so the (slow) dependency layer stays
# cached until a package.json or the root lockfile actually changes.
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/

# `npm ci` is the reproducible path; fall back to `npm install` when the
# committed lockfile and package.json are out of sync so the image still
# builds (dependency drift is tracked separately from this issue).
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund

COPY frontend ./frontend

RUN npm run build --workspace frontend

# ============================================================================
# Stage 2 — production runner
# Copies only the traced standalone output (never the workspace node_modules)
# and runs as the non-root `node` user that ships with the Alpine base image.
# ============================================================================
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS runner

WORKDIR /app

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

# The standalone bundle mirrors the monorepo layout, so the entrypoint is
# `frontend/server.js` and that prefix must be preserved. `.next/static` and
# `public/` are not part of the standalone output and are copied explicitly.
COPY --from=builder --chown=node:node /app/frontend/.next/standalone ./
COPY --from=builder --chown=node:node /app/frontend/.next/static ./frontend/.next/static
COPY --from=builder --chown=node:node /app/frontend/public ./frontend/public

USER node

EXPOSE 3000

# Native healthcheck against the app's own /health route handler so Compose /
# Kubernetes can gate traffic on readiness, not just process liveness.
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/health || exit 1

CMD ["node", "frontend/server.js"]
5 changes: 5 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import path from "node:path";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
// Emit a self-contained server bundle (`.next/standalone`) carrying only the
// traced production dependencies. The production Docker image copies this
// instead of the full `.next` tree + node_modules, keeping the runtime image
// small (issue #1335).
output: "standalone",
// The workspace root lives one level above this directory. Pinning it here
// prevents Turbopack from inferring a wrong root when stray package-lock
// files exist outside the repo (e.g. ~/package-lock.json).
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Lightweight liveness endpoint used by the container HEALTHCHECK
* (issue #1335). Intentionally dependency-free so it stays fast and always
* available, even while other routes are still warming up.
*/
export function GET() {
return Response.json({ status: "ok" });
}
Loading