Commit
Add the container build and deploy script
commit
dc4d3c3
5 changed files with +197 and −5
Jump to a changed file
- Dockerfile +19 −4
- deploy.sh +91 −0
- docker-compose.vps.yml +86 −0
- docker/entrypoint.sh +1 −1
- public/.gitkeep +0 −0
modified Dockerfile +19 −4
| @@ -30,6 +30,20 @@ENV BUILD_STANDALONE=1 | ||
| 30 | 30 | RUN pnpm prisma generate |
| 31 | 31 | RUN pnpm build |
| 32 | 32 | |
| 33 | +# --- Prisma CLI for the entrypoint ---------------------------------------- | |
| 34 | +# The entrypoint applies the schema at boot, which needs the CLI and its whole | |
| 35 | +# dependency tree. Lifting that out of the pnpm store means chasing symlinks | |
| 36 | +# through one store directory per transitive package, so it is installed once | |
| 37 | +# more here with npm, whose flat layout copies as a single directory. | |
| 38 | +# | |
| 39 | +# The version is read from package.json, so it can never drift from the client | |
| 40 | +# the app was generated against. | |
| 41 | +FROM base AS prismacli | |
| 42 | +WORKDIR /prisma-cli | |
| 43 | +COPY package.json ./ | |
| 44 | +RUN npm install --omit=dev --no-package-lock \ | |
| 45 | + "prisma@$(node -p "require('./package.json').devDependencies.prisma")" | |
| 46 | + | |
| 33 | 47 | # --- Runtime image --------------------------------------------------------- |
| 34 | 48 | FROM base AS runner |
| 35 | 49 | ENV NODE_ENV=production |
| @@ -44,11 +58,12 @@COPY --from=builder /app/public ./public | ||
| 44 | 58 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ |
| 45 | 59 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static |
| 46 | 60 | |
| 47 | -# Prisma CLI + engine + schema so the entrypoint can apply the schema at boot. | |
| 61 | +# Schema plus the CLI that applies it at boot. The CLI stays outside | |
| 62 | +# ./node_modules on purpose: a second @prisma directory beside the standalone | |
| 63 | +# bundle would shadow the generated client Next already traced into it, and the | |
| 64 | +# app would start against an empty client. | |
| 48 | 65 | COPY --from=builder /app/prisma ./prisma |
| 49 | -COPY --from=builder /app/node_modules/prisma ./node_modules/prisma | |
| 50 | -COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma | |
| 51 | -COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma | |
| 66 | +COPY --from=prismacli /prisma-cli/node_modules /prisma-cli/node_modules | |
| 52 | 67 | |
| 53 | 68 | COPY --chown=nextjs:nodejs docker/entrypoint.sh ./entrypoint.sh |
| 54 | 69 | RUN chmod +x ./entrypoint.sh |
added deploy.sh +91 −0
| @@ -0,0 +1,91 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# One-command redeploy for roundtable.rasmusj.com. | |
| 3 | +# | |
| 4 | +# Ships the source to the VPS, builds the image there and restarts the stack | |
| 5 | +# described by docker-compose.vps.yml. Run from Git Bash on Windows: ./deploy.sh | |
| 6 | +# | |
| 7 | +# Two things this script deliberately never touches: | |
| 8 | +# | |
| 9 | +# .env lives on the server only. It holds ENCRYPTION_KEY, which every | |
| 10 | +# saved BYOK key is encrypted with: overwrite it and those keys | |
| 11 | +# can never be read back. | |
| 12 | +# Caddyfile the shared reverse proxy at /opt/caddy serves every site on the | |
| 13 | +# box, so a routine redeploy cannot take the others down with it. | |
| 14 | +# | |
| 15 | +# Postgres lives in a named Docker volume and survives every rebuild. | |
| 16 | +# | |
| 17 | +# ./deploy.sh build, restart, wait for health | |
| 18 | +# ./deploy.sh --seed the same, then re-seed the public /demo debates | |
| 19 | +set -euo pipefail | |
| 20 | + | |
| 21 | +# Deploy target. Kept out of the repository on purpose: this code is shared | |
| 22 | +# read-only with people outside the project, and the server address and | |
| 23 | +# login are not theirs to have. Set it once in your shell: | |
| 24 | +# | |
| 25 | +# export VPS=user@host | |
| 26 | +# | |
| 27 | +VPS=${VPS:?set VPS=user@host before deploying} | |
| 28 | +DEST=/opt/projects/roundtable | |
| 29 | +COMPOSE="docker compose -f docker-compose.vps.yml" | |
| 30 | + | |
| 31 | +cd "$(dirname "$0")" | |
| 32 | + | |
| 33 | +SEED=0 | |
| 34 | +[ "${1:-}" = "--seed" ] && SEED=1 | |
| 35 | + | |
| 36 | +echo "==> Running the test suite before shipping anything..." | |
| 37 | +# Git Bash on Windows does not see a globally installed pnpm, so fall back to | |
| 38 | +# the one npx fetches rather than failing before anything has been checked. | |
| 39 | +if command -v pnpm >/dev/null 2>&1; then PNPM=pnpm; else PNPM="npx --yes pnpm"; fi | |
| 40 | +$PNPM test | |
| 41 | + | |
| 42 | +echo "==> Ensuring VPS project dir exists..." | |
| 43 | +ssh "$VPS" "mkdir -p '$DEST'" | |
| 44 | + | |
| 45 | +echo "==> Checking that .env exists on the server..." | |
| 46 | +if ! ssh "$VPS" "test -f '$DEST/.env'"; then | |
| 47 | + echo "" | |
| 48 | + echo " $DEST/.env is missing. Create it on the server first, see DEPLOY.md." | |
| 49 | + echo " Nothing has been changed." | |
| 50 | + exit 1 | |
| 51 | +fi | |
| 52 | + | |
| 53 | +echo "==> Uploading source (node_modules, .next, .env and coverage stay out of it)..." | |
| 54 | +tar --exclude=node_modules --exclude=.next --exclude=.env --exclude=.git \ | |
| 55 | + --exclude=coverage --exclude=tsconfig.tsbuildinfo -czf - . | ssh "$VPS" " | |
| 56 | + rm -rf '$DEST/src.tmp' && | |
| 57 | + mkdir -p '$DEST/src.tmp' && | |
| 58 | + tar -C '$DEST/src.tmp' -xzf - && | |
| 59 | + cp '$DEST/.env' '$DEST/src.tmp/.env' && | |
| 60 | + rm -rf '$DEST/app' && | |
| 61 | + mv '$DEST/src.tmp' '$DEST/app' | |
| 62 | +" | |
| 63 | + | |
| 64 | +echo "==> Building and restarting the stack..." | |
| 65 | +ssh "$VPS" "cd '$DEST/app' && $COMPOSE up -d --build" | |
| 66 | + | |
| 67 | +if [ "$SEED" = 1 ]; then | |
| 68 | + echo "==> Seeding the public demo debates..." | |
| 69 | + # The runtime image is the pruned standalone bundle and has no tsx, so the | |
| 70 | + # seed runs from the build stage instead, on the same network as Postgres. | |
| 71 | + ssh "$VPS" "cd '$DEST/app' && | |
| 72 | + docker build --target builder -t roundtable-seed . && | |
| 73 | + docker run --rm --network roundtable_internal --env-file .env \ | |
| 74 | + -e DATABASE_URL=\"postgresql://roundtable:\$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)@db:5432/roundtable?schema=public\" \ | |
| 75 | + roundtable-seed pnpm db:seed | |
| 76 | + " | |
| 77 | +fi | |
| 78 | + | |
| 79 | +echo "==> Waiting for the health check to go green..." | |
| 80 | +ssh "$VPS" " | |
| 81 | + for i in \$(seq 1 20); do | |
| 82 | + state=\$(docker inspect -f '{{.State.Health.Status}}' roundtable 2>/dev/null || echo starting) | |
| 83 | + [ \"\$state\" = healthy ] && echo ' healthy' && exit 0 | |
| 84 | + sleep 3 | |
| 85 | + done | |
| 86 | + echo ' still not healthy, check: docker logs roundtable' | |
| 87 | + exit 1 | |
| 88 | +" | |
| 89 | + | |
| 90 | +echo "" | |
| 91 | +echo "==> Done. Live at https://roundtable.rasmusj.com" |
added docker-compose.vps.yml +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +# roundtable.rasmusj.com, the shared-host variant of docker-compose.yml. | |
| 2 | +# | |
| 3 | +# The VPS already runs one Caddy for every site on it, so this file leaves TLS | |
| 4 | +# and the domain out entirely: nothing is published to the host and the proxy | |
| 5 | +# reaches the app by container name over the external `web` network. The domain | |
| 6 | +# lives in /opt/caddy/Caddyfile alone. | |
| 7 | +# | |
| 8 | +# Postgres stays off `web`. Every other project on the box shares that network, | |
| 9 | +# and a database has no business being reachable from any of them. | |
| 10 | +# | |
| 11 | +# Deploy with ./deploy.sh, which builds this on the server. | |
| 12 | + | |
| 13 | +# Named explicitly because every project on this host is deployed into a | |
| 14 | +# directory called `app`, and Compose would otherwise derive the same project | |
| 15 | +# name for all of them and treat the neighbours' containers as orphans. | |
| 16 | +name: roundtable | |
| 17 | + | |
| 18 | +services: | |
| 19 | + db: | |
| 20 | + image: postgres:16-alpine | |
| 21 | + container_name: roundtable-db | |
| 22 | + restart: unless-stopped | |
| 23 | + environment: | |
| 24 | + POSTGRES_USER: roundtable | |
| 25 | + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} | |
| 26 | + POSTGRES_DB: roundtable | |
| 27 | + volumes: | |
| 28 | + - roundtable-pgdata:/var/lib/postgresql/data | |
| 29 | + networks: | |
| 30 | + - internal | |
| 31 | + healthcheck: | |
| 32 | + test: ['CMD-SHELL', 'pg_isready -U roundtable -d roundtable'] | |
| 33 | + interval: 5s | |
| 34 | + timeout: 5s | |
| 35 | + retries: 12 | |
| 36 | + logging: | |
| 37 | + driver: json-file | |
| 38 | + options: | |
| 39 | + max-size: '10m' | |
| 40 | + max-file: '3' | |
| 41 | + | |
| 42 | + app: | |
| 43 | + build: | |
| 44 | + context: . | |
| 45 | + image: roundtable:vps | |
| 46 | + container_name: roundtable | |
| 47 | + restart: unless-stopped | |
| 48 | + depends_on: | |
| 49 | + db: | |
| 50 | + condition: service_healthy | |
| 51 | + environment: | |
| 52 | + DATABASE_URL: postgresql://roundtable:${POSTGRES_PASSWORD}@db:5432/roundtable?schema=public | |
| 53 | + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} | |
| 54 | + AUTH_TRUST_HOST: 'true' | |
| 55 | + OPENROUTER_APP_URL: ${OPENROUTER_APP_URL:-https://roundtable.rasmusj.com} | |
| 56 | + OPENROUTER_APP_TITLE: ${OPENROUTER_APP_TITLE:-Roundtable} | |
| 57 | + # Inference is always paid by the visitor's own OpenRouter key, so the | |
| 58 | + # public site costs nothing to run. /demo replays seeded debates and needs | |
| 59 | + # no key at all. | |
| 60 | + MOCK_LLM: ${MOCK_LLM:-0} | |
| 61 | + RATE_LIMIT_DEBATES_PER_HOUR: ${RATE_LIMIT_DEBATES_PER_HOUR:-10} | |
| 62 | + PORT: '3000' | |
| 63 | + networks: | |
| 64 | + - web | |
| 65 | + - internal | |
| 66 | + expose: | |
| 67 | + - '3000' | |
| 68 | + healthcheck: | |
| 69 | + test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] | |
| 70 | + interval: 30s | |
| 71 | + timeout: 5s | |
| 72 | + start_period: 40s | |
| 73 | + retries: 3 | |
| 74 | + logging: | |
| 75 | + driver: json-file | |
| 76 | + options: | |
| 77 | + max-size: '10m' | |
| 78 | + max-file: '3' | |
| 79 | + | |
| 80 | +volumes: | |
| 81 | + roundtable-pgdata: | |
| 82 | + | |
| 83 | +networks: | |
| 84 | + web: | |
| 85 | + external: true | |
| 86 | + internal: |
modified docker/entrypoint.sh +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | set -e |
| 6 | 6 | |
| 7 | 7 | echo "→ Applying database schema…" |
| 8 | -node node_modules/prisma/build/index.js db push --skip-generate | |
| 8 | +node /prisma-cli/node_modules/prisma/build/index.js db push --skip-generate --schema ./prisma/schema.prisma | |
| 9 | 9 | |
| 10 | 10 | echo "→ Starting Roundtable on port ${PORT:-3000}…" |
| 11 | 11 | exec node server.js |
added public/.gitkeep +0 −0
Line changes are not available for this file.