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
37 changes: 37 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Build context is the repo root (combined frontend+backend image). Keep it
# small: only frontend/ and backend/ sources are needed, and their deps are
# installed inside the image.
**/node_modules
**/dist
**/.venv
**/.git
**/.DS_Store
**/*.log

# Everything unrelated to the single-container build.
.git
.github
.venv
.husky
.vscode
.devcontainer
docs
backlog
experiment
google-docs-addon
prototype-uist
own-words
sandbox
scripts
__pycache__
*.pyc

# Backend runtime data must never be baked into the image (mounted at runtime).
backend/logs
backend/data
backend/.env

# Frontend dev/test artifacts not needed for the production build.
frontend/playwright-report
frontend/test-results
frontend/tests
72 changes: 72 additions & 0 deletions .github/workflows/build-addin-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Build add-in image

# Builds the single-container production add-in image (Hono backend serving the
# built frontend + /api/*, from the repo-root Dockerfile) and pushes it to GHCR
# on every push to main, tagged with the commit SHA. CD (in the
# Infrastructure_k8s_* repo) pins a deploy by reading main's current SHA and
# selecting that image tag — same pattern as build-experiment-image.yml.
#
# This runs in parallel with the existing Jenkins docker-compose build during
# the deployment migration; either path produces a working image.
on:
push:
branches:
- main
paths:
- 'frontend/**'
- 'backend/**'
- 'Dockerfile'
- '.dockerignore'
- '.github/workflows/build-addin-image.yml'
workflow_dispatch:

# Least privilege: read the repo to check it out, write packages to push to GHCR.
permissions:
contents: read
packages: write

# One image per commit on main (CD may pin an intermediate SHA), so don't cancel
# in-progress builds for the same ref.
concurrency:
group: build-addin-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Build and push to GHCR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

- name: Compute tags and labels
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/aitoolslab/writing-tools-addin
# Bare SHA tag is what CD pins to; latest is a convenience pointer.
tags: |
type=raw,value=${{ github.sha }}
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
# Repo root: the combined image needs both frontend/ and backend/.
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
42 changes: 42 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Single-container production image: the Hono backend serves both the built
# frontend (frontend/dist) and the /api/* routes. Build context is the repo
# ROOT (it needs both frontend/ and backend/).
#
# Local dev is unaffected — developers still run the Vite dev-server and
# `npm run dev` separately. This image is a deploy concern only.

# 1) Build the frontend -> /frontend/dist (Vite). Includes the Google Docs
# sidebar bundle (dist/google-docs.bundle.js), same as the old frontend image.
FROM node:24-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build && npm run build:google-docs

# 2) Build the backend -> /app/backend/dist (tsc).
FROM node:24-slim AS backend
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json* ./
RUN npm install
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npm run build

# 3) Runtime: Node serving the compiled backend + the frontend as static files.
FROM node:24-slim AS run
WORKDIR /app/backend
ENV NODE_ENV=production
# STATIC_ROOT (backend/src/static.ts) and LOG_DIR default to ./public and
# ./logs; compose sets LOG_DIR to the mounted data volume.
COPY backend/package.json backend/package-lock.json* ./
RUN npm install --omit=dev
COPY --from=backend /app/backend/dist ./dist
# Frontend build output becomes the static root (STATIC_ROOT=./public).
COPY --from=frontend /frontend/dist ./public
# The Apps Script sidebar loads the Google Docs bundle by absolute URL at
# /gdocs/google-docs.bundle.js (its PROD_BASE points there), so expose it at
# that path in addition to the dist root.
COPY --from=frontend /frontend/dist/google-docs.bundle.js ./public/gdocs/google-docs.bundle.js
EXPOSE 5000
CMD ["node", "dist/index.js"]
22 changes: 0 additions & 22 deletions backend/Dockerfile

This file was deleted.

6 changes: 6 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PORT,
} from './config.js';
import { shutdownPosthog } from './posthog.js';
import { serveFrontend } from './static.js';

if (!openaiApiKey()) {
console.warn('OPENAI_API_KEY is not set; /api/openai/* requests will fail.');
Expand Down Expand Up @@ -66,6 +67,11 @@ if (auth && DEBUG) {

}

// Serve the built frontend (production single-container deploy). Registered
// last so every /api/* route above — including the dynamic device/debug ones —
// takes precedence. No-op in local dev, where the static root doesn't exist.
serveFrontend(app);

const server = serve(
{ fetch: app.fetch, port: PORT, hostname: '0.0.0.0' },
(info) => console.log(`Backend listening on ${info.address}:${info.port}`),
Expand Down
44 changes: 44 additions & 0 deletions backend/src/static.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { existsSync } from 'node:fs';
import { serveStatic } from '@hono/node-server/serve-static';
import type { Context, Hono } from 'hono';

// The built frontend (`frontend/dist`) is copied here in the production image
// (see the repo-root Dockerfile). `serveStatic` resolves this relative to the
// process cwd, which is /app/backend in the container. In local dev this
// directory doesn't exist and frontend serving is skipped entirely.
const STATIC_ROOT = process.env.STATIC_ROOT ?? './public';

// Vite emits content-hashed bundles and assets as `name-<hash>.<ext>` (the hash
// is 8 base64url chars: A-Za-z0-9_-), all under assets/. Those filenames change
// every build, so they're safe to cache forever. Note this is NOT webpack's
// `.<hex>.` convention the old nginx.conf matched — that regex never matched a
// single Vite asset, silently downgrading them to the short cache below.
const HASHED =
/-[A-Za-z0-9_-]{8,}\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|webp)$/;
const ASSET = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|webp)$/;

function setCacheHeaders(path: string, c: Context): void {
if (path.endsWith('.html') || path.endsWith('manifest.xml')) {
// CRITICAL correctness rule: HTML entry points reference hashed bundles
// by name, so a cached HTML pins clients to old bundles after a deploy.
// manifest.xml is fetched by Office and must stay fresh too. With hashed
// filenames this no-store is the *only* cache rule that affects
// correctness; the immutable/short rules below are pure optimization.
c.header('Cache-Control', 'no-store, must-revalidate');
// Office silently refuses a manifest served as the wrong type.
if (path.endsWith('manifest.xml')) c.header('Content-Type', 'application/xml');
} else if (HASHED.test(path)) {
c.header('Cache-Control', 'public, max-age=31536000, immutable');
} else if (ASSET.test(path)) {
c.header('Cache-Control', 'public, max-age=3600, must-revalidate');
}
}

// Register static serving for the built frontend. Call this AFTER every /api/*
// route is registered so the API always wins; unmatched non-API GETs fall
// through to a 404 (the frontend is a multi-page app — there is no SPA
// index.html fallback). `serveStatic` serves index.html for `/`.
export function serveFrontend(app: Hono): void {
if (!existsSync(STATIC_ROOT)) return;
app.get('*', serveStatic({ root: STATIC_ROOT, onFound: setCacheHeaders }));
}
11 changes: 3 additions & 8 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
services:
frontend:
ports:
- "5001:80"
environment:
- NODE_ENV=production

# Combined container takes over the old frontend public port (5001).
backend:
ports:
- "5000:5000"
- "5001:5000"
environment:
- PORT=5000
- DEBUG=True
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LOG_SECRET=${LOG_SECRET:-}
volumes:
- ./backend/logs:/app/backend/logs
# Single persistent volume: holds auth.db and logs/ (LOG_DIR points here).
- ./backend/data:/app/backend/data

experiment:
Expand Down
17 changes: 8 additions & 9 deletions docker-compose-prod.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
services:
frontend:
ports:
- "19571:80"
environment:
- NODE_ENV=production

# Combined container takes over the old frontend public port (19571).
backend:
ports: []
ports:
- "19571:5000"
environment:
- PORT=5000
- DEBUG=False
- OPENAI_API_KEY=${OPENAI_API_KEY}
- POSTHOG_PROJECT_TOKEN=${POSTHOG_PROJECT_TOKEN:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://e.thoughtful-ai.com/}
volumes:
- /opt/thoughtful/logs:/app/backend/logs
- /opt/thoughtful/auth:/app/backend/data
# Single persistent volume (auth.db + logs/). One-time host migration:
# mkdir -p /opt/thoughtful/data/logs
# mv /opt/thoughtful/auth/auth.db /opt/thoughtful/data/
# mv /opt/thoughtful/logs/* /opt/thoughtful/data/logs/
- /opt/thoughtful/data:/app/backend/data

experiment:
ports:
Expand Down
17 changes: 8 additions & 9 deletions docker-compose-staging.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
services:
frontend:
ports:
- "19573:80"
environment:
- NODE_ENV=production

# Combined container takes over the old frontend public port (19573).
backend:
ports: []
ports:
- "19573:5000"
environment:
- PORT=5000
- DEBUG=False
- OPENAI_API_KEY=${OPENAI_API_KEY}
- POSTHOG_PROJECT_TOKEN=${POSTHOG_PROJECT_TOKEN:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://e.thoughtful-ai.com/}
volumes:
- /opt/thoughtful/staging-logs:/app/backend/logs
- /opt/thoughtful/staging-auth:/app/backend/data
# Single persistent volume (auth.db + logs/). One-time host migration:
# mkdir -p /opt/thoughtful/staging-data/logs
# mv /opt/thoughtful/staging-auth/auth.db /opt/thoughtful/staging-data/
# mv /opt/thoughtful/staging-logs/* /opt/thoughtful/staging-data/logs/
- /opt/thoughtful/staging-data:/app/backend/data

experiment:
ports:
Expand Down
17 changes: 8 additions & 9 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
depends_on:
- backend
restart: unless-stopped

# Single public web container: the Hono backend serves both the built frontend
# (frontend/dist) and the /api/* routes. The old separate nginx `frontend`
# service is gone; the combined image is built from the repo-root Dockerfile.
backend:
build:
context: ./backend
context: .
dockerfile: Dockerfile
environment:
- PORT=5000
# Persist study logs and the auth SQLite DB under one mounted volume
# (/app/backend/data). auth.db already lives there; LOG_DIR moves logs
# under it too, so a single volume covers all persistent state.
- LOG_DIR=/app/backend/data/logs
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LOG_SECRET=${LOG_SECRET}
- POSTHOG_PROJECT_TOKEN=${POSTHOG_PROJECT_TOKEN:-}
Expand Down
Loading