diff --git a/docs/web/architecture.md b/docs/web/architecture.md index ee389bd5..0d3e6c64 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -565,11 +565,12 @@ are not seeded either. **Environments**: -| Environment | Compose file | -| ----------- | -------------------------- | -| Development | `docker-compose.dev.yaml` | -| Testing | `docker-compose.test.yaml` | -| Production | `docker-compose.prod.yaml` | +| Environment | Compose file | +| --------------- | ------------------------------- | +| Development | `docker-compose.dev.yaml` | +| Testing | `docker-compose.test.yaml` | +| Production | `docker-compose.prod.yaml` | +| Production seed | `docker-compose.prod.seed.yaml` | For details, see [Docker Guide](docker.md). diff --git a/docs/web/diagrams/deployment.md b/docs/web/diagrams/deployment.md index ec52c4c9..69427d7f 100644 --- a/docs/web/diagrams/deployment.md +++ b/docs/web/diagrams/deployment.md @@ -41,7 +41,7 @@ no data flowing over them. | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | | **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. | -| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. | +| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. | | **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | | **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | | `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | diff --git a/docs/web/diagrams/deployment/prod.mmd b/docs/web/diagrams/deployment/prod.mmd index 6c267b92..e45283ea 100644 --- a/docs/web/diagrams/deployment/prod.mmd +++ b/docs/web/diagrams/deployment/prod.mmd @@ -1,11 +1,13 @@ sequenceDiagram box Production Environment (docker-compose.prod.yaml) participant ProdDB as SQL Server 2022
Biergarten Database
no host port published - participant ProdMig as Migrations (run-once) + participant ProdS3 as SeaweedFS
S3 gateway :8333
no host port published participant ProdAPI as API Container
ASP.NET Core 10
:8080 HTTP / :8081 HTTPS + participant ProdWeb as Frontend Container
Express + React Router
127.0.0.1:5656 end - ProdMig-->>ProdDB: depends_on - ProdMig->>ProdDB: Run migrations - ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed) + ProdAPI-->>ProdS3: depends_on: seaweedfs healthy + ProdWeb-->>ProdAPI: depends_on: api.core healthy ProdAPI->>ProdDB: Connect and serve + ProdAPI->>ProdS3: Store and read photos + Note over ProdDB: Migrating and seeding are separate run-once jobs,
run at deploy time rather than by the stack diff --git a/docs/web/docker.md b/docs/web/docker.md index ef05717b..537e495f 100644 --- a/docs/web/docker.md +++ b/docs/web/docker.md @@ -134,27 +134,136 @@ containers, which would end the run prematurely. See - Production logging levels - No database clearing - Optimized build configurations -- Health checks enabled -- Restart policies (unless-stopped) +- Health checks on every long-running service +- Restart policies that survive a reboot (see + [Restart tolerance](#restart-tolerance)) - Security hardening -- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are - reachable only from other containers on `prodnet` +- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse + proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are + reachable only from other containers on `prodnet` (named `biergarten-prodnet` + so the seed job can join it from its own Compose project) +- Seeding is a separate, explicitly run compose file + (`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing + catalogue as it is. See [Production data](#production-data) below. **Services**: ```yaml -sqlserver # Production SQL Server (internal only) -database.migrations # Schema updates only +sqlserver # Production SQL Server (internal only) api.core # Production API (internal only, no published ports) -frontend # Production website (port 3000, the only public binding) +seaweedfs # S3-compatible object storage (internal only) +frontend # Production website (127.0.0.1:5656, the only host binding) ``` +Migrating and seeding are run-once jobs kept outside this file, so an `up` or a +restart touches neither the schema nor the catalogue. Run them explicitly at +deploy time: see [Migrating production](#migrating-production) and +[Seeding production](#seeding-production-docker-composeprodseedyaml). + **Deploy Production**: ```bash +# One time, before the first `up`: the data volumes are external, so Compose +# expects them to exist already. +docker volume create biergartendata-prod +docker volume create seaweedfsdata-prod + docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d ``` +Docker seeds each volume with the ownership the image expects and labels it for +SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on +enforcing distributions such as RHEL, AlmaLinux and Fedora. + +#### Migrating production + +`database.migrations` is a run-once job rather than a service in the prod stack, +so the schema is migrated when an operator asks for it instead of on every boot. +Build the image and run it against the network the prod stack creates: + +```bash +docker build -t database.migrations \ + -f web/backend/Database/Database.Migrations/Dockerfile \ + --build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \ + web/backend + +docker run --rm --env-file web/.env.prod --network biergarten-prodnet \ + -e DOTNET_RUNNING_IN_CONTAINER=true database.migrations +``` + +DbUp records every script it has applied, so a re-run applies only what is new. +Run this with `sqlserver` already up and healthy, and before the seed job. + +#### Restart tolerance + +`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under +`restart: unless-stopped`, so the stack returns on its own after a container +crash, a Docker daemon restart or a host reboot, provided Docker itself starts at +boot (`systemctl enable docker`). The migration and seed jobs stay out of the +stack, so a reboot replays neither. + +The daemon replays restart policies without replaying the `depends_on` graph, so +after a reboot the containers come back in arbitrary order and each one tolerates +a dependency that arrives late. `api.core` opens its database and S3 connections +per request rather than at startup, and `frontend` answers with its own error +states while `api.core` is still coming up. + +Health checks report when the stack has converged: + +| Service | Probe | +| --- | --- | +| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries | +| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client | +| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up | +| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API | + +`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to +finish on shutdown instead of being recovered on the next start. + +Deploy and wait for the stack to report healthy, or check convergence after a +reboot: + +```bash +docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait +docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps +``` + +#### Seeding production (`docker-compose.prod.seed.yaml`) + +`database.seed` lives in its own compose file, run on demand once the prod stack +is up, `sqlserver` is healthy, and the schema is migrated: + +```bash +docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \ + run --rm --build database.seed +``` + +The job runs as the `biergarten-prod-seed` Compose project and attaches to the +external `biergarten-prodnet` network, which the main prod stack creates, so it +reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It +exits when seeding finishes; `--rm` removes the container. Bring the prod stack +up first, because the seed project has no visibility into the other project's +health checks, so a run started before SQL Server is accepting connections fails. + +#### Production data + +A production deployment seeded this way serves the AI-generated fixture data +produced by the pipeline. Per +[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md), +anyone interacting with an application seeded from it has to be told upfront that +the content is AI-generated. + +The website meets that obligation with `AiDisclosureDialog` +(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which +the root route renders on every full page load, whichever route the visitor lands +on. It is part of the server-rendered HTML rather than mounted after hydration, so +the disclosure is on screen with the first paint. Acknowledging it hides the +dialog for the remainder of that page load; a new load shows it again. + +Keep the seed job and the dialog together: any deployment that runs +`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the +ethics documentation. + ### 4. Database only (`docker-compose.db.yaml`) **Purpose**: Run just the database for local backend development outside Docker @@ -215,6 +324,12 @@ graph TD In development, `api.core` also depends on `seaweedfs` (`condition: service_started`, not a health check) alongside `database.seed`. +Production has a shorter chain, because the migration and seed jobs run outside +the stack: `api.core` waits for `seaweedfs` to pass its health check, and +`frontend` waits for `api.core` to report healthy. This ordering applies to +`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens +on a host reboot. + **Health Check Example** (SQL Server): ```yaml @@ -255,8 +370,10 @@ api.core: **Production**: -- `sqlserverdata-prod` - Production database files -- `nuget-cache-prod` - Production NuGet cache +- `biergartendata-prod` - Database files, external; survives `down -v` +- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v` + +See [Production data volumes](#production-data-volumes). ### Mounted volumes @@ -269,6 +386,41 @@ volumes: Test results are written to host filesystem for CI/CD integration. +### Production data volumes + +`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`. +Compose removes only the volumes it owns, so `docker compose down -v` tears the +stack down and leaves the database and the uploaded photos in place. Deleting +them takes an explicit `docker volume rm`. + +The trade-off is that Compose creates neither one, and `up` fails with a clear +error until both exist, hence the `docker volume create` step in +[Deploy Production](#3-production-docker-composeprodyaml). External volumes also +carry their literal name rather than a `_` prefix, so `docker volume ls` +shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the +`web_`-prefixed volumes of the dev and test stacks. + +Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point +the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at +`/var/lib/docker`. + +Back up the database through SQL Server rather than by copying its files, since a +file copy taken while the engine is running can be torn: + +```bash +docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P '' -C \ + -Q "BACKUP DATABASE [] TO DISK = '/var/opt/mssql/data/backup.bak'" +docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak . +``` + +Object storage can be archived directly, with the stack stopped: + +```bash +docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \ + alpine tar czf /backup/seaweedfs.tar.gz -C /data . +``` + ## Networks Each environment uses isolated bridge networks: diff --git a/docs/web/environment-variables.md b/docs/web/environment-variables.md index d47afe53..656d0cc1 100644 --- a/docs/web/environment-variables.md +++ b/docs/web/environment-variables.md @@ -317,6 +317,7 @@ from the template in place; edit them manually if you go that route. - `web/docker-compose.dev.yaml` → `web/.env.dev` - `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.prod.yaml` → `web/.env.prod` +- `web/docker-compose.prod.seed.yaml` → `web/.env.prod` ## Variable reference table diff --git a/web/docker-compose.prod.seed.yaml b/web/docker-compose.prod.seed.yaml new file mode 100644 index 00000000..bc46f2af --- /dev/null +++ b/web/docker-compose.prod.seed.yaml @@ -0,0 +1,32 @@ +# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed +name: biergarten-prod-seed + +services: + database.seed: + env_file: ".env.prod" + image: database.seed + container_name: prod-env-database-seed + build: + context: ./backend + dockerfile: Database/Database.Seed/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_SERVER: "${DB_SERVER}" + DB_NAME: "${DB_NAME}" + DB_USER: "${DB_USER}" + DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" + restart: "no" + networks: + - prodnet + +networks: + prodnet: + name: biergarten-prodnet + external: true diff --git a/web/docker-compose.prod.yaml b/web/docker-compose.prod.yaml index d2248d07..4b99d938 100644 --- a/web/docker-compose.prod.yaml +++ b/web/docker-compose.prod.yaml @@ -6,10 +6,10 @@ services: container_name: prod-env-sqlserver environment: ACCEPT_EULA: "Y" - SA_PASSWORD: "${DB_PASSWORD}" + MSSQL_SA_PASSWORD: "${DB_PASSWORD}" MSSQL_PID: "Express" volumes: - - sqlserverdata-prod:/var/opt/mssql + - biergartendata-prod:/var/opt/mssql healthcheck: test: [ @@ -20,29 +20,8 @@ services: timeout: 5s retries: 12 start_period: 30s - networks: - - prodnet - - database.migrations: - env_file: ".env.prod" - image: database.migrations - container_name: prod-env-database-migrations - depends_on: - sqlserver: - condition: service_healthy - build: - context: ./backend - dockerfile: Database/Database.Migrations/Dockerfile - args: - BUILD_CONFIGURATION: Release - APP_UID: 1000 - environment: - DOTNET_RUNNING_IN_CONTAINER: "true" - DB_SERVER: "${DB_SERVER}" - DB_NAME: "${DB_NAME}" - DB_USER: "${DB_USER}" - DB_PASSWORD: "${DB_PASSWORD}" - restart: "no" + restart: unless-stopped + stop_grace_period: 60s networks: - prodnet @@ -51,7 +30,7 @@ services: image: api.core container_name: prod-env-api-core depends_on: - sqlserver: + seaweedfs: condition: service_healthy build: context: ./backend @@ -59,8 +38,6 @@ services: args: BUILD_CONFIGURATION: Release APP_UID: 1000 - # No published ports: only the frontend service is reachable from outside - # the Docker network in production. api.core stays internal to prodnet. environment: ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_URLS: "http://0.0.0.0:8080" @@ -73,11 +50,45 @@ services: REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" + healthcheck: + test: + [ + "CMD", + "bash", + "-c", + 'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "', + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s restart: unless-stopped networks: - prodnet + + seaweedfs: + env_file: ".env.prod" + image: chrislusf/seaweedfs + container_name: prod-env-seaweedfs + environment: + AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + S3_BUCKET: "${SEAWEEDFS_BUCKET}" volumes: - - nuget-cache-prod:/root/.nuget/packages + - seaweedfsdata-prod:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + networks: + - prodnet frontend: env_file: ".env.prod" @@ -85,27 +96,34 @@ services: container_name: prod-env-frontend depends_on: api.core: - condition: service_started + condition: service_healthy build: context: ./frontend dockerfile: Dockerfile ports: - - "3000:3000" + - "127.0.0.1:5656:5656" environment: NODE_ENV: "production" - PORT: "3000" + PORT: "5656" API_BASE_URL: "http://api.core:8080" SESSION_SECRET: "${SESSION_SECRET}" + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 15s restart: unless-stopped networks: - prodnet volumes: - sqlserverdata-prod: - driver: local - nuget-cache-prod: - driver: local + biergartendata-prod: + external: true + seaweedfsdata-prod: + external: true networks: prodnet: + name: biergarten-prodnet driver: bridge diff --git a/web/frontend/app/features/home/components/AiDisclosureDialog.stories.tsx b/web/frontend/app/features/home/components/AiDisclosureDialog.stories.tsx new file mode 100644 index 00000000..5424163f --- /dev/null +++ b/web/frontend/app/features/home/components/AiDisclosureDialog.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import AiDisclosureDialog from './AiDisclosureDialog'; + +const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`; + +const meta = { + title: 'Disclosure/AiDisclosureDialog', + component: AiDisclosureDialog, + tags: ['autodocs'], + args: { + defaultOpen: true, + }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: aiDisclosureDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(async () => { + await expect( + canvas.getByRole('heading', { name: /this content is ai-generated/i }), + ).toBeVisible(); + }); + await expect( + canvas.getByRole('link', { name: /how this data was generated/i }), + ).toBeVisible(); + }, +}; + +export const Acknowledged: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /i understand/i })); + await expect( + canvas.queryByRole('heading', { name: /this content is ai-generated/i }), + ).toBeNull(); + }, +}; + +export const InitiallyDismissed: Story = { + args: { + defaultOpen: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole('heading', { name: /this content is ai-generated/i }), + ).toBeNull(); + }, +}; diff --git a/web/frontend/app/features/home/components/AiDisclosureDialog.tsx b/web/frontend/app/features/home/components/AiDisclosureDialog.tsx new file mode 100644 index 00000000..d2d1b8f0 --- /dev/null +++ b/web/frontend/app/features/home/components/AiDisclosureDialog.tsx @@ -0,0 +1,79 @@ +import { Sparks } from 'iconoir-react'; +import { useEffect, useState } from 'react'; + +const ethicsDocUrl = + 'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md'; + +const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => { + const [open, setOpen] = useState(defaultOpen); + + useEffect(() => { + if (!open) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open]); + + if (!open) { + return null; + } + + return ( +
+
+
+ + +

+ This content is AI-generated +

+

+ The Biergarten App is a proof of concept. Every brewery, beer, and user in + this catalogue is fixture data produced by a language model — none of it + describes a real business, person, or beer. +

+
+ + Brewing details, local-language text, and coordinates are known to + contain fabrications. Treat nothing here as factual. + +
+ + How this data was generated + +
+
+ +
+
+
+ ); +}; + +export default AiDisclosureDialog; diff --git a/web/frontend/app/root.tsx b/web/frontend/app/root.tsx index ad4b3a59..ff6097e9 100644 --- a/web/frontend/app/root.tsx +++ b/web/frontend/app/root.tsx @@ -13,6 +13,7 @@ import './app.css'; import Navbar from './components/ui/navbar/Navbar'; import ToastProvider from './components/ui/toast/ToastProvider'; import { getOptionalAuth } from './features/auth/auth.server'; +import AiDisclosureDialog from './features/home/components/AiDisclosureDialog'; import { defaultThemeName, parseThemeCookie } from './features/theme/themes'; export const links: Route.LinksFunction = () => [ @@ -60,6 +61,7 @@ const App = ({ loaderData }: Route.ComponentProps) => { <> + ); diff --git a/web/frontend/server.js b/web/frontend/server.js index 0fb51811..ef77795d 100644 --- a/web/frontend/server.js +++ b/web/frontend/server.js @@ -6,6 +6,12 @@ const app = express(); app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' })); app.use(express.static('build/client', { maxAge: '1h' })); +// Answers from Express alone so the container health check reports on this +// process only, independent of whether the API is reachable yet. +app.get('/healthz', (_req, res) => { + res.status(200).type('text/plain').send('ok'); +}); + const requestHandler = createRequestHandler({ build: () => import('./build/server/index.js'), });