diff --git a/.dockerignore b/.dockerignore index 3274faf..338b8d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,10 @@ -# Nixpacks/Docker build context — do NOT ignore .mutiny (Docker cannot +# Docker build context — do NOT ignore .mutiny (Docker cannot # re-include files under an excluded parent dir). Sample adapter must ship. .env .env.* !.env.example +**/.env +**/.env.* __pycache__/ *.py[cod] *$py.class @@ -26,5 +28,9 @@ node_modules/ .worktrees/ .vscode/ .idea/ +.git/ +.codex/ +.agents/ +.uv-cache/ apps/web/node_modules/ apps/web/.next/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a912316..84f42b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,57 @@ jobs: - name: Build run: npm run build + container-smoke: + name: container-smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + MUTINY_API_TOKEN: container-ci-dummy-token + COMPOSE_PROJECT_NAME: mutiny-ci + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and start production images + run: docker compose up --build -d + + - name: Check readiness and container boundaries + run: | + set -euo pipefail + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8000/api/health >/dev/null && + curl --fail --silent http://127.0.0.1:3000 >/dev/null; then + break + fi + sleep 2 + done + curl --fail http://127.0.0.1:8000/api/health + curl --fail --silent http://127.0.0.1:3000 >/dev/null + # Same-origin proxy must reach the API and inject the runtime token. + curl --fail http://127.0.0.1:3000/api/campaigns + docker compose exec -T api sh -c 'test "$(id -u)" = 10001 && test -w /app/data && test ! -w /app/.venv && test ! -w /app/examples' + docker compose exec -T web sh -c 'test "$(id -u)" != 0' + # The named volume is the only API mount; web has no host mounts. + test "$(docker inspect --format '{{len .Mounts}}' "$(docker compose ps -q api)")" = 1 + test "$(docker inspect --format '{{(index .Mounts 0).Type}}' "$(docker compose ps -q api)")" = volume + test "$(docker inspect --format '{{len .Mounts}}' "$(docker compose ps -q web)")" = 0 + + - name: Confirm missing token fails before listen + run: | + set +e + timeout 15 docker compose run --rm --no-deps -e MUTINY_API_TOKEN= api + status=$? + test "$status" = 2 + + - name: Container logs + if: failure() + run: docker compose logs --no-color + + - name: Clean up test containers and data + if: always() + run: docker compose down -v + package-build: name: package-build runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db083e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim-bookworm AS api-build +COPY --from=ghcr.io/astral-sh/uv:0.12.23 /uv /usr/local/bin/uv +WORKDIR /app +ENV UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +COPY pyproject.toml uv.lock ./ +COPY packages ./packages +COPY apps/api ./apps/api +COPY apps/demo_agent ./apps/demo_agent +RUN uv sync --locked --no-dev --no-editable --package mutiny-api + +FROM node:22-bookworm-slim AS web-build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY apps/web/package.json apps/web/package-lock.json ./ +RUN npm ci +COPY apps/web ./ +# Next.js rewrites are baked into the build. Never pass the API token here. +ARG MUTINY_API_URL=http://api:8000 +ENV MUTINY_API_URL=$MUTINY_API_URL +RUN npm run build + +FROM node:22-bookworm-slim AS web +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000 +COPY --from=web-build --chown=node:node /app/.next/standalone ./ +COPY --from=web-build --chown=node:node /app/.next/static ./.next/static +USER node +EXPOSE 3000 +CMD ["node", "server.js"] + +# Keep API last: Railway builds this default target from the repository root. +FROM python:3.12-slim-bookworm AS api +WORKDIR /app +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ + MUTINY_DB_PATH=/app/data/mutiny.sqlite +RUN groupadd --gid 10001 mutiny && useradd --uid 10001 --gid mutiny --no-create-home mutiny \ + && mkdir /app/data && chown mutiny:mutiny /app/data +COPY --from=api-build /app/.venv /app/.venv +COPY examples ./examples +COPY config ./config +COPY scripts/ensure_sample_mutiny.py ./scripts/ensure_sample_mutiny.py +# Preserve the Railway sample-ensure behavior, but prepare files during build. +RUN python scripts/ensure_sample_mutiny.py +USER 10001:10001 +EXPOSE 8000 +CMD ["python", "-m", "mutiny_api", "--host", "0.0.0.0", "--port", "8000"] diff --git a/SECURITY.md b/SECURITY.md index 40e1216..50dfc69 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,6 +129,34 @@ mutiny db restore --from /secure/backups/mutiny-YYYYMMDD.sqlite --db /path/to/mu **Not provided:** automated off-site replication, PostgreSQL, or Web `POST /api/db/*` endpoints. +## Container deployment (MUT-012 / #63) + +Compose builds the API and production Next.js UI from the root Dockerfile. +Python dependencies are installed with `uv sync --locked` and JavaScript +dependencies with `npm ci` during image build. Restarts use the built virtual +environment / standalone server; no package installation occurs at startup. +Rebuild images to apply dependency or base-image updates. Lockfiles constrain +application dependencies; image tags and build tooling still require trusted +registries and a trusted build environment. + +The API runs as UID/GID 10001 and the UI as the image's `node` user. Source and +dependencies in the API image are root-owned. Compose mounts only the named +`mutiny-data` volume at `/app/data`, with no host checkout bind mount. Both host +ports bind to `127.0.0.1`. The API still listens on `0.0.0.0` inside its container +and requires `MUTINY_API_TOKEN` through `python -m mutiny_api` before listening. + +The UI proxies that shared operator token for its visitors. Keep it on a trusted +local machine: exposing port 3000 through a public proxy requires separate +access control. Non-root execution and restricted mounts reduce host exposure; +containers still share the host kernel and are not a sandbox for hostile agents. + +Railway builds the default API image and uses its installed Python environment. +Its startup keeps the existing sample-ensure check; customer execution remains +removed. Configure a persistent volume at `/app/data` writable by UID/GID 10001 +if durable storage is needed. New Docker named volumes inherit image directory +ownership; existing or externally provisioned volumes need permissions set by +the operator. See [COLD_START](./docs/COLD_START.md) for commands and backup notes. + ## Reporting a vulnerability Please **do not** open a public GitHub issue for security-sensitive reports. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 83022ed..e10402a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const API = process.env.MUTINY_API_URL || "http://127.0.0.1:8000"; const nextConfig: NextConfig = { + output: "standalone", async rewrites() { return [ { diff --git a/apps/web/src/app/fonts/IBM-Plex-Mono-LICENSE.txt b/apps/web/src/app/fonts/IBM-Plex-Mono-LICENSE.txt new file mode 100644 index 0000000..83eac82 --- /dev/null +++ b/apps/web/src/app/fonts/IBM-Plex-Mono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/app/fonts/README.md b/apps/web/src/app/fonts/README.md new file mode 100644 index 0000000..28ae9ec --- /dev/null +++ b/apps/web/src/app/fonts/README.md @@ -0,0 +1,14 @@ +# Bundled UI fonts + +The UI loads Latin Rubik (variable, weights 400–800) and IBM Plex Mono +(weights 400, 500, 600) through `next/font/local`. Builds do not fetch Google +Fonts CSS or font files. + +Unmodified WOFF2 files were extracted from these npm packages, with the archive +SHA-512 checked against the registry's `dist.integrity`: + +- `@fontsource-variable/rubik@5.3.0`: `files/rubik-latin-wght-normal.woff2` +- `@fontsource/ibm-plex-mono@5.3.0`: `files/ibm-plex-mono-latin-{400,500,600}-normal.woff2` + +Both fonts use the SIL Open Font License 1.1. Their copyright notices and +licenses are included beside the font files. Preserve these when updating fonts. diff --git a/apps/web/src/app/fonts/Rubik-LICENSE.txt b/apps/web/src/app/fonts/Rubik-LICENSE.txt new file mode 100644 index 0000000..b25d078 --- /dev/null +++ b/apps/web/src/app/fonts/Rubik-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik) Rubik-Italic[wght].ttf: Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/app/fonts/ibm-plex-mono-latin-400-normal.woff2 b/apps/web/src/app/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/apps/web/src/app/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/apps/web/src/app/fonts/ibm-plex-mono-latin-500-normal.woff2 b/apps/web/src/app/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/apps/web/src/app/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/apps/web/src/app/fonts/ibm-plex-mono-latin-600-normal.woff2 b/apps/web/src/app/fonts/ibm-plex-mono-latin-600-normal.woff2 new file mode 100644 index 0000000..67aeeb0 Binary files /dev/null and b/apps/web/src/app/fonts/ibm-plex-mono-latin-600-normal.woff2 differ diff --git a/apps/web/src/app/fonts/rubik-latin-wght-normal.woff2 b/apps/web/src/app/fonts/rubik-latin-wght-normal.woff2 new file mode 100644 index 0000000..42fc52a Binary files /dev/null and b/apps/web/src/app/fonts/rubik-latin-wght-normal.woff2 differ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e2376ee..121917a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,18 +1,23 @@ import type { Metadata } from "next"; -import { Rubik, IBM_Plex_Mono } from "next/font/google"; +import localFont from "next/font/local"; import { AppShell } from "@/components/AppShell"; import "./globals.css"; -const rubik = Rubik({ +const rubik = localFont({ + src: "./fonts/rubik-latin-wght-normal.woff2", variable: "--font-rubik", - subsets: ["latin"], - weight: ["400", "500", "600", "700", "800"], + weight: "400 800", + display: "swap", }); -const plexMono = IBM_Plex_Mono({ +const plexMono = localFont({ + src: [ + { path: "./fonts/ibm-plex-mono-latin-400-normal.woff2", weight: "400" }, + { path: "./fonts/ibm-plex-mono-latin-500-normal.woff2", weight: "500" }, + { path: "./fonts/ibm-plex-mono-latin-600-normal.woff2", weight: "600" }, + ], variable: "--font-plex-mono", - subsets: ["latin"], - weight: ["400", "500", "600"], + display: "swap", }); export const metadata: Metadata = { diff --git a/docker-compose.yml b/docker-compose.yml index a82a2b3..7d1d7d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,12 @@ services: api: - image: python:3.12-slim - working_dir: /app + build: + context: . + target: api volumes: - # Bind-mount keeps MUTINY_DB_PATH (/app/data/...) on the host ./data/ - - .:/app - command: > - bash -lc "pip install -q uv && uv sync && - PYTHONPATH=apps/api/src:apps/demo_agent/src:packages/mutiny_core/src - uv run python -m mutiny_api --host 0.0.0.0 --port 8000" + - mutiny-data:/app/data ports: - - "8000:8000" + - "127.0.0.1:8000:8000" environment: # Honored by mutiny_api.db.resolve_db_path (M-PR4). - MUTINY_DB_PATH=/app/data/mutiny.sqlite @@ -18,18 +14,20 @@ services: # Set the same value on `web` so the Next rewrite can inject Authorization. - MUTINY_API_TOKEN=${MUTINY_API_TOKEN:?set MUTINY_API_TOKEN for non-loopback Hosted} web: - image: node:22-bookworm - working_dir: /app/apps/web - volumes: - - .:/app - command: bash -lc "npm install && npm run dev -- --hostname 0.0.0.0 --port 3000" + build: + context: . + target: web + args: + MUTINY_API_URL: http://api:8000 ports: - - "3000:3000" + - "127.0.0.1:3000:3000" depends_on: - api environment: - - WATCHPACK_POLLING=true # Same token as `api` so middleware can inject Authorization on rewrites. # Use the Docker service hostname — 127.0.0.1 is wrong inside this container. - MUTINY_API_TOKEN=${MUTINY_API_TOKEN:?set MUTINY_API_TOKEN for non-loopback Hosted} - - MUTINY_API_URL=http://api:8000 + # API destination is set at image build time above. + +volumes: + mutiny-data: diff --git a/docs/COLD_START.md b/docs/COLD_START.md index 50282b4..4169e7a 100644 --- a/docs/COLD_START.md +++ b/docs/COLD_START.md @@ -3,7 +3,7 @@ | Field | Value | |---|---| | **Status** | Canonical clean-machine bootstrap | -| **Last updated** | 2026-08-09 | +| **Last updated** | 2026-10-24 | | **Pin** | [`config/demo_pin.json`](../config/demo_pin.json) | Use this on a clean machine. Competitive claims stay frozen to [COMPETITOR_ANALYSIS.md](./COMPETITOR_ANALYSIS.md). @@ -86,7 +86,60 @@ Verify: - [ ] Open `http://127.0.0.1:3000` — safety banner visible - [ ] Narrative: sample/reference agent, not “this is the product” -**docker-compose:** use when you want containerized API + web (`docker compose up --build`). Prefer `scripts/dev.sh` for local iteration. Compose sets `MUTINY_DB_PATH=/app/data/mutiny.sqlite` (honored by the API). The bind mount `.:/app` keeps that file on the host under `./data/`; there is no separate named volume. Compose binds the API to `0.0.0.0`, so **`MUTINY_API_TOKEN` is required** (P0-1/P0-4) — unset/empty fails closed before listen. Set the same value on `api` and `web`. Compose also sets `MUTINY_API_URL=http://api:8000` on `web` so Next rewrites reach the API service (not `127.0.0.1`). +### Containerized local demo (MUT-012 / #63) + +Set `MUTINY_API_TOKEN` in your shell or an uncommitted `.env` file, then run: + +```bash +docker compose up --build -d +curl -f http://127.0.0.1:8000/api/health +# Open http://127.0.0.1:3000 +docker compose down +``` + +Both host ports publish only on `127.0.0.1`. The UI acts for its visitors using +the operator token, so keep it local or behind your own authenticated proxy. +Compose passes the same token to both services. The API's internal `0.0.0.0` +bind still fails closed without a token; the supported entrypoint remains +`python -m mutiny_api`. + +Dependencies are installed at image build (`uv sync --locked --no-dev +--no-editable --package mutiny-api`, `npm ci`, `npm run build`). Runtime uses +non-root users and the prebuilt Python environment / Next.js standalone server. +Rebuild after dependency or source changes; use `scripts/dev.sh` for hot reload. +The web image's API rewrite destination is a **build argument**, +`MUTINY_API_URL` (Compose: `http://api:8000`), because Next.js bakes rewrites into +its build. The token is runtime-only; never supply it as a build argument. + +Only a named `mutiny-data` volume is mounted at `/app/data`; neither container +mounts the host checkout. The database is `/app/data/mutiny.sqlite`, not the +host's `./data/mutiny.sqlite`. `docker compose down` retains the volume; +`docker compose down -v` deletes it. To back it up using the documented SQLite +backup API, stage the backup inside the volume and copy it out: + +```bash +docker compose exec api python -c "import sqlite3; source=sqlite3.connect('/app/data/mutiny.sqlite'); dest=sqlite3.connect('/app/data/backup.sqlite'); source.backup(dest); dest.close(); source.close()" +docker compose cp api:/app/data/backup.sqlite ./mutiny-backup.sqlite +``` + +Keep backups private. Existing host `./data/` databases are not migrated +automatically; back them up and restore deliberately before switching. An +existing volume or an optional data-only bind mount must be writable by API +UID/GID `10001:10001`; fresh named volumes inherit image directory ownership. + +### Railway API image + +`railway.toml` selects the root Dockerfile's final `api` stage. Build dependencies +are already installed; startup runs the existing sample-ensure check and then +`python -m mutiny_api`, expanding Railway's `PORT`. Set `MUTINY_API_TOKEN` in the +service environment. No `uv sync` or package manager runs at boot. The sample +files are prepared during image build, preserving the existing ensure behavior +(MUT-066 is separate). + +For persistence, attach a volume at `/app/data` with ownership allowing UID/GID +`10001:10001` to write it. Without a volume, database state is ephemeral across +replacement deployments. This change does not provide public UI authentication +or multi-tenant isolation; see [SECURITY.md](../SECURITY.md#container-deployment-mut-012--63). --- diff --git a/railway.toml b/railway.toml index 922bf05..14377a9 100644 --- a/railway.toml +++ b/railway.toml @@ -1,12 +1,14 @@ # Mutiny Hosted API — Railway deploy from monorepo root [build] -builder = "nixpacks" +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" +# The final Dockerfile stage is the API image. [deploy] # Non-loopback bind (0.0.0.0) requires MUTINY_API_TOKEN (P0-1/P0-4); # python -m mutiny_api fails closed before listen if missing/empty. -# Sample .mutiny may be stripped from the image (Docker cannot re-include under -# an excluded .mutiny/ parent). Always ensure adapter exists before listen. -startCommand = "uv sync && mkdir -p data && uv run python scripts/ensure_sample_mutiny.py && uv run python -m mutiny_api --host 0.0.0.0 --port ${PORT:-8000}" -restartPolicyType = "on_failure" +# Keep the existing sample-ensure step (MUT-066 is separate). The image already +# prepares these files at build time, so this is normally a read-only check. +startCommand = "sh -c 'python scripts/ensure_sample_mutiny.py && exec python -m mutiny_api --host 0.0.0.0 --port ${PORT:-8000}'" +restartPolicyType = "ON_FAILURE" restartPolicyMaxRetries = 10