Root cause of "service images don't show on the Mac after pulling latest": the SPA is baked into the nginx (`web`) image and the API binary is baked into the `api` image at `docker compose build` time. Running just `git pull && docker compose up -d` reuses the existing images, so new static assets in `artifacts/tx-os/public/` (and any front-end / API code changes) never reach the browser. Changes: - New `scripts/redeploy.sh` — single-command redeploy. Steps: 1. git pull --ff-only (skip with --no-pull) 2. docker compose build api web 3. docker compose run --rm migrate (drizzle push + idempotent seed) 4. docker compose up -d Detects `docker compose` v2 vs legacy `docker-compose`, runs from any cwd via BASH_SOURCE, set -euo pipefail, --help prints the header. chmod +x. Verified `bash -n` and `--help`. - `replit.md` — new "Redeploying after `git pull` (Docker hosts)" section that calls out the gotcha and points at the script. - `DEPLOYMENT_MIGRATION.md` — one-line cross-reference at the top of the deploy section so anyone reading deploy docs finds the script. No code or runtime behavior changed. No follow-ups proposed: the only natural next step (PWA full-screen) is already tracked as Task #550.
9.8 KiB
Deployment Migration Report
Migration of Tx OS from a hosted single-tenant runtime to a self-contained Docker Compose stack runnable on any Linux host (or macOS workstation with Docker Desktop).
Summary
Tx OS previously depended on three runtime surfaces tied to its original hosting environment:
- A managed object-storage sidecar (
http://127.0.0.1:1106) used by the API server to mint signed upload/download URLs against a managed GCS bucket via@google-cloud/storage+google-auth-library. - Three Vite plugins (
@*/vite-plugin-cartographer,@*/vite-plugin-dev-banner,@*/vite-plugin-runtime-error-modal) loaded conditionally in the SPA and the component sandbox. - Hosted-platform configuration files consumed by the previous runtime.
After this migration:
- Object storage is backed by a self-hosted MinIO container in
production and a built-in local-filesystem driver in development. Both
are abstracted behind a
StoredObjectinterface inlib/objectStorage.tsso the route layer is driver-agnostic. - Hosted-platform Vite plugins are removed from both
vite.config.tsfiles and frompnpm-workspace.yaml's catalog. - A
Dockerfile(5 build targets — deps / build / api / web / migrate) and adocker-compose.yml(db + minio + minio-init + api + web + one-shot migrate) bring the entire stack up withdocker compose up -d. A new.env.exampletemplate documents every env var the runtime reads.
The repository is now self-contained: cloning it onto a Docker-equipped host and running the documented commands produces a working deployment.
Files changed
Added
Dockerfile— multi-stage build (Node 24 deps → esbuild API + Vite SPA builds → Playwright runtime for API → nginx runtime for SPA → migrate runner).docker-compose.yml— full production stack with healthchecks and one-shot init/migrate containers.docker/nginx.conf— SPA static serve +/apiand/api/socket.ioreverse proxy to the api container..env.example— exhaustive, commented configuration template.README.md— canonical project doc; covers Docker quickstart, local dev, env reference, and a production checklist.DEPLOYMENT_MIGRATION.md— this file.artifacts/api-server/src/routes/storage-local-upload.ts— handler for the local-FS driver's HMAC-signed PUT upload URLs (PUT /api/storage/_local/upload).
Replaced (full rewrite)
artifacts/api-server/src/lib/objectStorage.ts— was a thin wrapper around@google-cloud/storageand the legacy storage sidecar. Now ships a driver interface with two implementations (LocalDriver,S3Driver) selected by theSTORAGE_DRIVERenv var (defaulting tos3ifS3_ENDPOINTis set, elselocal). Public API surface is preserved byte-compatible so callers inroutes/storage.tsandroutes/executive-meetings.tsdid not need to change.artifacts/api-server/src/lib/objectAcl.ts— dropped the@google-cloud/storageFile import in favour of a localStoredObjectinterface that both drivers implement. The deprecatedcanAccessObjectshim is retained (still always denies, per the original safe default).artifacts/tx-os/vite.config.tsandartifacts/mockup-sandbox/vite.config.ts— stripped all hosted-platform plugin imports and gated dynamic imports. The tx-os config now reads its API proxy target fromVITE_API_PROXY_TARGETfor arbitrary dev setups..gitignore— restructured so that hosted-platform configs, agent state, local storage, test artifacts, and any.env*(except.env.example) are excluded from git.
Edited
pnpm-workspace.yaml— removed the three hosted-platform catalog entries and emptiedminimumReleaseAgeExclude.artifacts/api-server/package.json— dropped@google-cloud/storageandgoogle-auth-library; added@aws-sdk/client-s3and@aws-sdk/s3-request-presigner.artifacts/tx-os/package.jsonandartifacts/mockup-sandbox/package.json— removed hosted-platform plugin dependencies.scripts/src/seed.ts— admin/user passwords now read fromSEED_ADMIN_PASSWORD/SEED_USER_PASSWORD; the script throws inNODE_ENV=productionif either is unset.threat_model.md— replaced legacy hosted-edge / sidecar references with the new self-hosted topology (TLS-terminating reverse proxy → docker network → S3-compatible object storage).
Deleted
attached_assets/— 23 MB of legacy uploaded assets unused by the codebase.artifacts/api-server/dist/,lib/*/dist/, all*.tsbuildinfo— build artefacts that should not be tracked.- Obsolete post-merge hook script consumed by the previous runtime.
Verification
pnpm install --frozen-lockfile— clean install with the new dependency set; pnpm reports+87 -69packages (S3 SDK in, GCS plugins out).pnpm --filter @workspace/api-server build— esbuild bundle succeeds.- API-server test suite — all 12 storage / object-authz tests
(
storage-object-authz.test.mjscases A through L) pass against the new local-FS driver, including the round-trip presigned PUT → DB-backed authz check → streamed GET.
Operational notes for first-time deploy
Updating an existing deploy after
git pull? Use./scripts/redeploy.sh— it rebuilds theapiandwebimages, runs migrations + seed, and restarts the stack in the right order. Seereplit.md→ "Redeploying aftergit pull".
cp .env.example .envand fill in every value.docker compose builddocker compose up -d db minio minio-init— Postgres + MinIO + create buckets.docker compose run --rm migrate— apply Drizzle schema + seed admin / user accounts (one-shot).docker compose up -d api web— boot the API and the SPA.- Front
web(port${WEB_PORT}) with a TLS-terminating reverse proxy.
The README's "Production checklist" lists the security-relevant steps required before fronting the stack with a public domain.
Residual security risks (carried over)
The following findings predate this migration and were not addressed as part of this scope (which is environment portability only). They remain on the security backlog.
Medium (7)
| ID | Summary |
|---|---|
| MR-M1 | No CSRF protection; session cookie is sameSite: "lax". |
| MR-M2 | No session regeneration on login (session-fixation window). |
| MR-M3 | Helmet / security headers are not set on API responses. |
| MR-M4 | GET /api/users/directory returns the full employee directory to every authenticated user. |
| MR-M5 | POST /api/apps/:id/open accepts any app id from any user (audit-log pollution + open enumeration). |
| MR-M6 | Errors caught in executive-meetings routes echo raw Error.message to the client. |
| MR-M8 | Upload contentType is taken from the client and never validated server-side. |
Note: MR-M7 ("Object Storage ACL subsystem is unimplemented") is now documented in-source by the new
objectAcl.tsdeprecation banner and the entity-lookup authz model inlib/objectAuthz.ts; no behaviour change is required for the migration to ship.
Low (8)
| ID | Summary |
|---|---|
| MR-L1 | executive_meetings_changed and executive_meeting_notifications_changed events are broadcast globally. |
| MR-L2 | Bcrypt cost factor is 10 (industry guidance is 12). |
| MR-L3 | Account enumeration on register (distinguishable error for existing username). |
| MR-L4 | No socket connection cap or handshake rate limit. |
| MR-L5 | GET /api/settings is unauthenticated. |
| MR-L6 | Several notes.ts mutation routes use ad-hoc body parsing instead of Zod. |
| MR-L7 | express.json() uses the default 100 KB body limit. |
| MR-L8 | GET /executive-meetings/me returns the caller's full role list. |
Unmigrated object-data risk
Object data stored in the legacy managed GCS bucket prior to this
migration is not automatically copied into MinIO. Operators standing
up a fresh MinIO instance start with empty buckets. If historical uploads
must be preserved, the operator is responsible for a one-time mc mirror
(or equivalent) from the legacy bucket into the new MinIO bucket; the API
server's signed-URL contract and DB-side attached_object_path columns
are already compatible because both backends key by <bucket>/<objectName>.
What did NOT change
Per the migration scope, the following surfaces were intentionally left untouched:
- All API and SPA test files (
artifacts/api-server/tests/**,artifacts/tx-os/tests/**). lib/db/scripts/**(DB push/pull scripts).- The PDF renderers (
pdf-renderer.ts,pdf-html-renderer.ts). - The SPA shell (
App.tsx,executive-meetings.tsx,upcoming-meeting-alert.tsx). - Locale catalogues (
ar.json,en.json). - The auto-generated React-Query client (
custom-fetch.ts). - The Executive Meetings DB schema (
schema/executive-meetings.ts). - Authentication, sessions, rate limiting, and CSRF/CORS middleware — only the storage subsystem was rewritten.