Repository navigation
Description
The Development and Deployment tutorials never mention file storage, but the .env they tell you to generate turns it on. Following either tutorial as written gives a broken instance. .env.template sets STORAGE_ENABLED=true with STORAGE_ENDPOINT=http://localhost:9000, and generate-env.sh fills in the keys, so the env schema passes.
- Development.
docker-compose.dev.yamlstarts only MongoDB, and nothing listens on port 9000.StorageService.onModuleInitsendsHeadBucketCommand, catches the failure, and then sends an unguardedCreateBucketCommand, which also rejects. The api exits during boot. Turbo stops the gateway and web dev servers with it, so the tutorial's finalpnpm devends inERROR run failed, and the setup screen never appears. The error never mentions storage. (.agents/docs/playbooks/run-locally.mddocuments this trap for agents, step 4, but the public tutorial does not.) - Deployment. The stack starts, because Compose provides rustfs. The api, however, signs file URLs with
STORAGE_PUBLIC_ENDPOINT, anddocker-compose.yamldefaults that tohttp://localhost:${APP_PORT}/storage. The tutorial says to "leave the other settings as the default values", so every presigned upload and download URL sent to a browser points athttp://localhost:5500/storage/.... That URL means the user's own machine. Every file instrument fails on a deployment that follows the guide. The changelog's 2.0.0 notes say to setSTORAGE_PUBLIC_ENDPOINT"for real deployments", but the tutorial never does.
Where
.env.template:78 and :87:
STORAGE_ENABLED=true
...
STORAGE_PUBLIC_ENDPOINT=apps/api/src/storage/storage.service.ts:93-103:
async onModuleInit(): Promise<void> {
if (!this.enabled || this.configService.get('NODE_ENV') === 'test') {
return;
}
const { bucket, s3 } = this.requireStorage();
try {
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
}
}docker-compose.yaml:43:
- STORAGE_PUBLIC_ENDPOINT=${STORAGE_PUBLIC_ENDPOINT:-http://localhost:${APP_PORT}/storage}docs/en/2-tutorials/2.0-development.mdx:63-69 (Configuration) and :93-112 (MongoDB only), then :172-176 (pnpm dev). docs/en/2-tutorials/2.1-deployment.md:83-97, where Step 4 edits only SITE_ADDRESS, GATEWAY_SITE_ADDRESS, APP_PORT and GATEWAY_PORT:
For the purposes of this guide, we will leave the other settings as the default values.Reproduce
Development:
- On a fresh clone, follow the Development tutorial:
./scripts/generate-env.sh, start the dev MongoDB, initiate the replica set,pnpm install,pnpm dev.
Actual: the api crashes on boot with a connection error to localhost:9000, turbo stops all three apps, and http://localhost:3000 never serves the setup screen.
Expected: following the tutorial ends at the setup screen.
Deployment:
- Follow the Deployment tutorial on a server with
SITE_ADDRESS=myplatform.com. - Sign in from another computer and complete a file instrument, such as the demo
ARBITRARY_SINGLE_FILE.
Actual: the browser PUTs the file to http://localhost:5500/storage/open-data-capture/..., which is the user's own computer, and the upload fails.
Expected: the presigned URL uses https://myplatform.com/storage/..., which Caddy proxies to rustfs.
Tests
docs/ has no test runner (docs/AGENTS.md). Verify by following each tutorial on a fresh clone: pnpm dev reaches the setup screen, and a file instrument completes on a deployment that follows the guide.
Suggested fix
- Development tutorial: add a step after "Configuration" to set
STORAGE_ENABLED=falsein.env, saying that file instruments then answer 503. Alternatively, add a rustfs service todocker-compose.dev.yamland say that it provides storage. - Deployment tutorial, Step 4: add
STORAGE_PUBLIC_ENDPOINT=https://myplatform.com/storageto the block of settings to change, and explain that it must be the public address of the core site.
This is separate from #1752, where rustfs itself fails to start on Linux because of a root-owned data directory. With that fixed, the deployment still hands browsers localhost URLs.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). The deployment half fails earlier than described: with Step 4's values as written, the api never starts, so it never issues a presigned URL.Deployment: Step 4 needs two more fixes
GATEWAY_SITE_ADDRESS=gateway.myplatform.com(docs/en/2-tutorials/2.1-deployment.md:88) has no scheme, and$Envparses it with$UrlLike(apps/api/src/core/schemas/env.schema.ts:16). Parsing$Envwith that value throwsTypeError: Invalid URL.https://gateway.myplatform.comparses. The throw escapesAppFactory.create(apps/api/src/main.ts:21) without naming the variable. The cause is that libjs 3.2.1's$UrlLikeuses a non-aborting.url()check, so zod still runs thenew URL()transform on the invalid string. With.url({ abort: true }), the same parse returns aninvalid_formatissue onGATEWAY_SITE_ADDRESS. That fix belongs in libjs. 3.2.1 is the latest release.SITE_ADDRESS=myplatform.com(:87) becomes the bundled Caddy's site address (Caddyfile:1). A hostname turns on Caddy's automatic HTTPS, so that Caddy answers port 80 with a redirect to HTTPS. The container publishes only port 80 (docker-compose.yaml:11). The Step 6reverse_proxypassesHostthrough unchanged (docs), so the browser should be redirected back to the URL it just requested, in a loop. I have not run this, so check it during the walkthrough. The inner Caddy needs the template'sSITE_ADDRESS=:80(.env.template:9).Add a sentence saying
SITE_ADDRESSstays:80because the Step 6 server terminates HTTPS. The storage value works as is. The api signs path-style URLs againsthttp://rustfs:9000(apps/api/src/storage/storage.module.ts:23,docker-compose.yaml:42), andtransformUrlForPublicAccessswaps only that prefix (apps/api/src/storage/storage.service.ts:120-125). Caddy strips/storageand sendsHost: rustfs:9000(Caddyfile:20-25), so the signature still matches. The browser PUTs to its own origin, so rustfs needs no CORS.In the same PR, rewrite
.env.template:83-84, which says the variable "may be left blank" in the production compose stack. The tutorial sends readers to these comments (2.1-deployment.md:97), but the compose default only works for a browser that opens the app athttp://localhost:5500on the server itself.On Linux the walkthrough cannot boot the api until #1752 lands. Until then, run
mkdir -p rustfs/data && chown 10001:10001 rustfs/databeforedocker compose up -d.Development
STORAGE_ENABLED=falseis the right fix. Adapt step 4 of.agents/docs/playbooks/run-locally.md, then edit that playbook's intro (:3-6), which listsSTORAGE_ENABLEDas something the tutorial does not have.docker-compose.dev.yamltakes more than a new service. Dev has no Caddy, so the browser onlocalhost:3000would PUT cross-origin tolocalhost:9000. Since 1.0.0-beta.2, rustfs sends no CORS headers unlessRUSTFS_CORS_ALLOWED_ORIGINSis set (GHSA-x5xv-223c-8vm7). The service would need that variable or a/storageproxy beside/api/inapps/web/vite.config.ts:68-76. On Linux, its bind-mounted data directory would also need the Fresh Linux deployments crash-loop rustfs and the API because ./rustfs/data is created root-owned #1752 fix. That belongs in its own Infrastructure issue.Optional: make the crash name storage
Wrap the
CreateBucketCommandfailure (storage.service.ts:100-102) in an error that namesSTORAGE_ENDPOINTand saysSTORAGE_ENABLED=falseturns storage off, with the original error ascause. That also makes #1752 diagnosable from the api log. The unit test goes in theonModuleInitblock ofapps/api/src/storage/__tests__/storage.service.test.ts. No e2e spec can reach this path:dev:testsetsNODE_ENV=test(apps/api/package.json:11), andonModuleInitreturns early under test (storage.service.ts:94). If it lands, update the "never says the word storage" text inrun-locally.md:13-14and.agents/skills/odc-run-locally/SKILL.md:18-22.Follow-up
A default that does not depend on the domain would remove the deployment trap. Accept a root-relative
STORAGE_PUBLIC_ENDPOINT=/storageand make it the default atdocker-compose.yaml:43. Onlyapps/webconsumes presigned URLs, and the same Caddy serves the web app and/storage. The change touches threez.url()schemas:env.schema.ts:22packages/schemas/src/storage/storage.ts:20packages/schemas/src/instrument-records/instrument-records.ts:108, whichapps/web/src/hooks/useInstrumentRecordFilesQuery.ts:9parsesIt also touches the PUT at
apps/web/src/routes/_app/instruments/render/$id.tsx:81, where axios would otherwise prefix its/apibase URL (apps/web/src/services/axios.ts:39). Give it its own issue. The docs fix should not wait for it.Triage note written with Claude Code. Code references point at
main@d86740e.