diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ccf6260 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Rebuilt inside the image +node_modules +**/node_modules +**/dist +packages/database/src/generated + +# Secrets and local data - these must never be baked into an image layer +**/.env +**/.env.* +*.db +*.db-journal +*.db-shm +*.db-wal + +# VCS, tooling and editor noise +.git +.gitignore +.github +.vscode +.idea +**/*.log + +# The build files themselves +Dockerfile +.dockerignore +docker-compose.yml diff --git a/.gitea/workflows/build-and-publish.yml b/.gitea/workflows/build-and-publish.yml new file mode 100644 index 0000000..e2b8be4 --- /dev/null +++ b/.gitea/workflows/build-and-publish.yml @@ -0,0 +1,27 @@ +name: Build and Publish Docker Image + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker Image + run: | + docker build -t git.alexav.gg/${{ gitea.repository }}:latest . + docker build -t git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }} . + + - name: Login to Gitea Registry + run: | + echo "${{ secrets.RUNNER_TOKEN }}" | docker login git.alexav.gg -u ${{ gitea.actor }} --password-stdin + + - name: Push Docker Image + run: | + docker push git.alexav.gg/${{ gitea.repository }}:latest + docker push git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5159836 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,104 @@ +# syntax=docker/dockerfile:1 + +# Prisma 7 supports Node 20.19+, 22.12+ and 24.x and prints an unsupported +# warning on newer majors, so this tracks the current LTS rather than latest. +ARG NODE_VERSION=24-slim +ARG PNPM_VERSION=11.25.0 + +# --------------------------------------------------------------------------- +# base - shared runtime, with pnpm baked in so no stage fetches it later +# --------------------------------------------------------------------------- +FROM node:${NODE_VERSION} AS base +ARG PNPM_VERSION +ENV PNPM_HOME=/pnpm \ + PATH=/pnpm:$PATH \ + CHECKPOINT_DISABLE=1 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate +WORKDIR /app + +# --------------------------------------------------------------------------- +# build - install, generate the Prisma client, compile all three packages +# --------------------------------------------------------------------------- +FROM base AS build + +# better-sqlite3's install step is `prebuild-install || node-gyp rebuild`, so a +# toolchain has to exist for the fallback. It never reaches a runtime stage. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +# Manifests first: dependencies only reinstall when the lockfile actually moves. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/backend/package.json apps/backend/ +COPY apps/frontend/package.json apps/frontend/ +COPY packages/database/package.json packages/database/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +COPY . . + +# `prisma generate` resolves datasource.url through prisma7.config.ts, so the +# variable must exist - but generate only writes the client, it never connects. +ENV DATABASE_URL="file:/tmp/build.db" +RUN pnpm --filter database db:generate \ + && pnpm --filter database build \ + && pnpm --filter backend build \ + && pnpm --filter frontend build + +# Everything is compiled, so drop devDependencies in place. Safe here because +# every runtime import in both apps resolves to a production dependency; the +# one devDependency reference, `import("vite")` in server.js, sits in the dev +# branch that --prod never takes. +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --prod --frozen-lockfile + +# --------------------------------------------------------------------------- +# backend - Express API. Not published to the host; the frontend proxies to it. +# --------------------------------------------------------------------------- +FROM base AS backend +ENV NODE_ENV=production + +COPY --from=build /app /app + +# Applying migrations on boot keeps a fresh volume usable. `migrate deploy` only +# applies migrations it has not already recorded, so restarts are a no-op. +COPY <<'EOF' /usr/local/bin/blog-backend +#!/bin/sh +set -e +cd /app/packages/database +./node_modules/.bin/prisma migrate deploy +cd /app/apps/backend +exec node dist/index.js +EOF +RUN chmod +x /usr/local/bin/blog-backend + +# The SQLite file lives on a volume mounted here. +RUN mkdir -p /data && chown node:node /data +USER node +WORKDIR /app/apps/backend + +# src/index.ts calls app.listen(3000) directly - env.PORT is parsed but unused. +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + +CMD ["blog-backend"] + +# --------------------------------------------------------------------------- +# frontend - SSR server: renders / and /post/:slug, proxies /api to the backend +# --------------------------------------------------------------------------- +FROM base AS frontend +ENV NODE_ENV=production \ + PORT=5173 \ + BACKEND_ORIGIN=http://backend:3000 + +COPY --from=build /app /app + +USER node +WORKDIR /app/apps/frontend + +EXPOSE 5173 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||5173)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + +CMD ["node", "server.js", "--prod"] diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 2eea525..8c5bcf6 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +dist \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 649b054..b02c745 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "build": "tsc" }, "keywords": [], "author": "", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ad93f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# Backend is intentionally not published to the host: the browser only ever +# talks to the frontend, which proxies /api to it over the internal network. +# That keeps sessions same-origin, exactly as the Vite dev proxy does. + +services: + redis: + image: redis:8-alpine + command: ["redis-server", "--save", "60", "1"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + + backend: + build: + context: . + target: backend + environment: + NODE_ENV: production + # No default in env.ts - the server exits on boot without it. + SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET in .env} + DATABASE_URL: file:/data/blog.db + REDIS_URL: redis://redis:6379 + volumes: + - blog-data:/data + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + context: . + target: frontend + environment: + NODE_ENV: production + PORT: 5173 + BACKEND_ORIGIN: http://backend:3000 + ports: + - "${FRONTEND_PORT:-5173}:5173" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +volumes: + blog-data: + redis-data: