Skip to content

Development and deployment tutorials leave file storage enabled but unconfigured, so pnpm dev crashes and deployed file uploads target localhost #1787

Open

Description

@joshunrau

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.yaml starts only MongoDB, and nothing listens on port 9000. StorageService.onModuleInit sends HeadBucketCommand, catches the failure, and then sends an unguarded CreateBucketCommand, which also rejects. The api exits during boot. Turbo stops the gateway and web dev servers with it, so the tutorial's final pnpm dev ends in ERROR run failed, and the setup screen never appears. The error never mentions storage. (.agents/docs/playbooks/run-locally.md documents 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, and docker-compose.yaml defaults that to http://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 at http://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 set STORAGE_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:

  1. 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:

  1. Follow the Deployment tutorial on a server with SITE_ADDRESS=myplatform.com.
  2. 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=false in .env, saying that file instruments then answer 503. Alternatively, add a rustfs service to docker-compose.dev.yaml and say that it provides storage.
  • Deployment tutorial, Step 4: add STORAGE_PUBLIC_ENDPOINT=https://myplatform.com/storage to 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

  1. added
    BugType: existing behavior is wrong
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Outreachapps/outreach and docs/ (marketing site and user documentation)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    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 $Env parses it with $UrlLike (apps/api/src/core/schemas/env.schema.ts:16). Parsing $Env with that value throws TypeError: Invalid URL. https://gateway.myplatform.com parses. The throw escapes AppFactory.create (apps/api/src/main.ts:21) without naming the variable. The cause is that libjs 3.2.1's $UrlLike uses a non-aborting .url() check, so zod still runs the new URL() transform on the invalid string. With .url({ abort: true }), the same parse returns an invalid_format issue on GATEWAY_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 6 reverse_proxy passes Host through 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's SITE_ADDRESS=:80 (.env.template:9).
    GATEWAY_SITE_ADDRESS=https://gateway.myplatform.com
    STORAGE_PUBLIC_ENDPOINT=https://myplatform.com/storage
    APP_PORT=5500
    GATEWAY_PORT=3500

    Add a sentence saying SITE_ADDRESS stays :80 because the Step 6 server terminates HTTPS. The storage value works as is. The api signs path-style URLs against http://rustfs:9000 (apps/api/src/storage/storage.module.ts:23, docker-compose.yaml:42), and transformUrlForPublicAccess swaps only that prefix (apps/api/src/storage/storage.service.ts:120-125). Caddy strips /storage and sends Host: 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 at http://localhost:5500 on 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/data before docker compose up -d.

    Development

    • STORAGE_ENABLED=false is the right fix. Adapt step 4 of .agents/docs/playbooks/run-locally.md, then edit that playbook's intro (:3-6), which lists STORAGE_ENABLED as something the tutorial does not have.
    • Adding rustfs to docker-compose.dev.yaml takes more than a new service. Dev has no Caddy, so the browser on localhost:3000 would PUT cross-origin to localhost:9000. Since 1.0.0-beta.2, rustfs sends no CORS headers unless RUSTFS_CORS_ALLOWED_ORIGINS is set (GHSA-x5xv-223c-8vm7). The service would need that variable or a /storage proxy beside /api/ in apps/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 CreateBucketCommand failure (storage.service.ts:100-102) in an error that names STORAGE_ENDPOINT and says STORAGE_ENABLED=false turns storage off, with the original error as cause. That also makes #1752 diagnosable from the api log. The unit test goes in the onModuleInit block of apps/api/src/storage/__tests__/storage.service.test.ts. No e2e spec can reach this path: dev:test sets NODE_ENV=test (apps/api/package.json:11), and onModuleInit returns early under test (storage.service.ts:94). If it lands, update the "never says the word storage" text in run-locally.md:13-14 and .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=/storage and make it the default at docker-compose.yaml:43. Only apps/web consumes presigned URLs, and the same Caddy serves the web app and /storage. The change touches three z.url() schemas:

    • env.schema.ts:22
    • packages/schemas/src/storage/storage.ts:20
    • the download URL at packages/schemas/src/instrument-records/instrument-records.ts:108, which apps/web/src/hooks/useInstrumentRecordFilesQuery.ts:9 parses

    It also touches the PUT at apps/web/src/routes/_app/instruments/render/$id.tsx:81, where axios would otherwise prefix its /api base 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Outreachapps/outreach and docs/ (marketing site and user documentation)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions