From 15ca249626aef308cc6f0813df3be7945de12fc1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 14:09:27 -0700 Subject: [PATCH 1/6] fix(self-hosting): stop publishing Postgres and require POSTGRES_PASSWORD The Compose files published the db service on every host interface, and the production file fell back to the password postgres when POSTGRES_PASSWORD was unset. The db is now reachable only over the Compose network, and the production file refuses to start without POSTGRES_PASSWORD. sim-setup writes the value an install needs before bringing it up: a generated password for a new database, and the legacy one for a volume created before it was required, since Postgres ignores POSTGRES_PASSWORD on an existing data directory. --- README.md | 2 +- .../docs/platform/self-hosting/docker.mdx | 6 +- .../docs/platform/self-hosting/security.mdx | 26 ++-- .../docs/platform/self-hosting/upgrades.mdx | 4 +- docker-compose.local.yml | 6 +- docker-compose.ollama.yml | 8 +- docker-compose.prod.yml | 19 ++- packages/cli/src/index.ts | 2 - .../sim-setup/src/compose-database.test.ts | 92 +++++++++++++ packages/sim-setup/src/compose-database.ts | 121 ++++++++++++++++++ packages/sim-setup/src/lifecycle.ts | 37 +++++- packages/sim-setup/src/modes/compose.ts | 22 ++++ 12 files changed, 317 insertions(+), 28 deletions(-) create mode 100644 packages/sim-setup/src/compose-database.test.ts create mode 100644 packages/sim-setup/src/compose-database.ts diff --git a/README.md b/README.md index f25af677768..5a186532d0c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Ask DeepWiki - Set Up with Cursor + Set Up with Cursor

diff --git a/apps/docs/content/docs/platform/self-hosting/docker.mdx b/apps/docs/content/docs/platform/self-hosting/docker.mdx index 9fa470a7bb1..df179c71bdf 100644 --- a/apps/docs/content/docs/platform/self-hosting/docker.mdx +++ b/apps/docs/content/docs/platform/self-hosting/docker.mdx @@ -49,7 +49,7 @@ EOF Save `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` somewhere outside this server. `ENCRYPTION_KEY` encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets; `API_ENCRYPTION_KEY` encrypts user-generated Sim API keys. Neither can be regenerated — a database restore paired with a different key leaves the data it protected permanently unreadable. -The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, or `INTERNAL_API_SECRET` is missing, rather than booting with empty values. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works. +The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET`, or `POSTGRES_PASSWORD` is missing, rather than booting with empty or well-known values. Postgres applies `POSTGRES_PASSWORD` only when it first creates the database volume — see [Postgres on Compose](/platform/self-hosting/security#postgres-on-compose) before changing it on an existing install. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works. Images track `latest` unless you pin them. For production, see [Upgrades](/platform/self-hosting/upgrades). @@ -65,7 +65,7 @@ Six services start: |---|---|---| | `simstudio` | 3000 | Main application (8 GB memory limit) | | `realtime` | 3002 | WebSocket server (1 GB memory limit) | -| `db` | 5432 | PostgreSQL 17 with pgvector | +| `db` | internal | PostgreSQL 17 with pgvector — not published to the host | | `redis` | internal | Pub/sub and shared cache — not published to the host | | `cron` | — | Runs the [background jobs](/platform/self-hosting/background-jobs) on a schedule | | `migrations` | — | Applies schema migrations once, then exits | @@ -206,5 +206,5 @@ npx sim-setup update { question: "Do scheduled workflows work on Docker Compose?", answer: "Yes. The cron service runs the same jobs the Helm chart schedules as Kubernetes CronJobs, using the schedules in docker/crontab. It needs CRON_SECRET — without it the service prints what to set and exits, and the rest of the stack keeps running."}, { question: "Why is there a Redis container?", answer: "Redis backs pub/sub for live Chat task status and table events, plus shared caches. Pub/sub has no fallback that works across processes, so live status would not stream without it. The port is deliberately not published so it cannot collide with a local Redis."}, { question: "How do I back up and restore the database?", answer: "Back up with: docker compose -f docker-compose.prod.yml exec -T db pg_dump -U postgres simstudio > backup.sql. The -T matters — without it exec allocates a TTY and corrupts the redirected dump. Restore with: docker compose -f docker-compose.prod.yml exec -T db psql -U postgres simstudio < backup.sql. The database data is persisted in a Docker volume named postgres_data."}, - { question: "Can I customize the PostgreSQL credentials?", answer: "Yes. The docker-compose.prod.yml uses environment variable defaults: POSTGRES_USER (default: postgres), POSTGRES_PASSWORD (default: postgres), POSTGRES_DB (default: simstudio), and POSTGRES_PORT (default: 5432). Set these in your .env file to override them." }, + { question: "Can I customize the PostgreSQL credentials?", answer: "Yes. Set POSTGRES_USER (default: postgres) and POSTGRES_DB (default: simstudio) in .env before the first start. POSTGRES_PASSWORD has no default — the compose file will not start without it. Postgres applies all three only when it creates the database volume, so changing them later does not change the existing database; to rotate the password, see Postgres on Compose in the security guide." }, ]} /> diff --git a/apps/docs/content/docs/platform/self-hosting/security.mdx b/apps/docs/content/docs/platform/self-hosting/security.mdx index 1bd8b0fb7a4..dd2b1f596f0 100644 --- a/apps/docs/content/docs/platform/self-hosting/security.mdx +++ b/apps/docs/content/docs/platform/self-hosting/security.mdx @@ -271,19 +271,29 @@ networkPolicy: The service bundles ~2.2 GB of spaCy models, so first start takes around three minutes and it needs at least 4 GB of memory. -## The shipped Compose file publishes Postgres +## Postgres on Compose - - `docker-compose.prod.yml` maps the database to the host: `${POSTGRES_PORT:-5432}:5432`, with `POSTGRES_USER` and `POSTGRES_PASSWORD` both defaulting to `postgres`. A plain `docker compose up -d` against that file, on a machine with a public interface, therefore exposes an open Postgres on 5432 with credentials anyone can guess. The local and Ollama stacks map the database the same way, so apply the fix to whichever file started your install. +The Compose files do not publish the `db` service to the host: `simstudio`, `realtime`, and `migrations` reach it over the Compose network as `db:5432`, and nothing outside the stack can. `docker-compose.prod.yml` also refuses to start without `POSTGRES_PASSWORD`, the same way it refuses to start without `BETTER_AUTH_SECRET`. - The [Docker guide](/platform/self-hosting/docker#1-configure-environment) tells you to generate `POSTGRES_PASSWORD` before the first start — do that, and additionally close the port: + + Installs created from an earlier Compose file published the database on every interface of the host (`5432:5432`), and a `POSTGRES_PASSWORD` left unset fell back to `postgres`. A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. Update the Compose file, then check what your database was created with: - - **Do not need host access.** Delete the `ports:` block from the `db` service. Every other service reaches it over the Compose network by name. - - **Need host access.** Bind it to loopback only — `127.0.0.1:${POSTGRES_PORT:-5432}:5432` — and reach it over an SSH tunnel. + - **You set `POSTGRES_PASSWORD` before the first start.** Nothing else to do — updating the file closes the port. + - **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD ''"`, set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out. - A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. + `npx sim-setup start` and `npx sim-setup update` write the right value for you and print the rotation steps. +To reach the database from the host — `psql`, a desktop client, a backup job — use `docker compose -f docker-compose.prod.yml exec db psql -U postgres simstudio`, or pass a second Compose file that publishes it on loopback only and connect over an SSH tunnel: + +```yaml +# db-port.yml — docker compose -f docker-compose.prod.yml -f db-port.yml up -d +services: + db: + ports: + - '127.0.0.1:5432:5432' +``` + ## Pre-launch checklist - All five secrets generated fresh, stored in a secret manager, and **`ENCRYPTION_KEY` backed up separately** @@ -297,7 +307,7 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m - NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller - Namespace labelled `pod-security.kubernetes.io/enforce=restricted` - Object storage buckets private, with CORS limited to your Sim origin -- Database reachable only from the deployment — on Compose, the `db` service's host `ports:` mapping removed or bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD` +- Database reachable only from the deployment — on Compose, no host `ports:` mapping on the `db` service, or one bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD` - TLS enforced (`sslMode: require`) on an externally managed database, or on the bundled one once you have configured it for TLS — the shipped Compose database does not enable it - Backups configured **and a restore rehearsed** - Sandbox strategy decided for user code diff --git a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx index 23a59933898..1006325ae40 100644 --- a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx +++ b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx @@ -193,11 +193,13 @@ install uses: | Install | What it runs | |---|---| -| `docker-compose.prod.yml` | Refreshes its managed copy of the Compose file, then `docker compose pull` — the versions configured by `SIM_VERSION`, or `latest` when unset | +| `docker-compose.prod.yml` | Refreshes its managed copy of the Compose file, writes `POSTGRES_PASSWORD` to `.env` if it is missing, then `docker compose pull` — the versions configured by `SIM_VERSION`, or `latest` when unset | | `docker-compose.local.yml` | `docker compose build --pull` — rebuilds from source against refreshed base images, no pull of published images | Inspect the result with `npx sim-setup logs`, which targets whichever Compose file the install uses. +`docker-compose.prod.yml` requires `POSTGRES_PASSWORD`. If you manage the file yourself and Compose stops with `required variable POSTGRES_PASSWORD is missing a value`, your database was created with the password `postgres` — set exactly that in `.env`, not a new value, then see [Postgres on Compose](/platform/self-hosting/security#postgres-on-compose) to rotate it. + The CLI detects only those two files. An install started from `docker-compose.ollama.yml` is invisible to it: `update`, `logs`, and `status` report no install, or — in a source checkout that also carries per-application env files — report that checkout's dev install instead. Upgrade that stack directly: ```bash diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 04b3a520c1a..48daa577a06 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -111,11 +111,13 @@ services: timeout: 5s retries: 5 + # Not published to the host: the other services reach it over the Compose + # network as db:5432. A host `ports:` mapping binds every interface, and the + # password below defaults to a well-known value. For host access use + # `docker compose exec db psql -U postgres simstudio`. db: image: pgvector/pgvector:pg17 restart: always - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 39104560346..4311732add4 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -87,12 +87,14 @@ services: command: ['bun', 'run', 'db:migrate'] restart: 'no' - # PostgreSQL Database with Vector Extension + # PostgreSQL Database with Vector Extension. + # Not published to the host: the other services reach it over the Compose + # network as db:5432. A host `ports:` mapping binds every interface, and the + # password below defaults to a well-known value. For host access use + # `docker compose exec db psql -U postgres simstudio`. db: image: pgvector/pgvector:pg17 restart: always - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2cea451fee6..0c85846041b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,7 +15,7 @@ services: memory: 8G environment: - NODE_ENV=production - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio} + - 'DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?is required. For a new install set it in .env to the output of: openssl rand -hex 24. An install created before it was required uses the password postgres - set POSTGRES_PASSWORD=postgres to keep its data.}@db:5432/${POSTGRES_DB:-simstudio}' - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} # TRUSTED_ORIGINS: comma-separated public origins to trust for auth in @@ -85,7 +85,7 @@ services: memory: 1G environment: - NODE_ENV=production - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio} + - 'DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?is required. For a new install set it in .env to the output of: openssl rand -hex 24. An install created before it was required uses the password postgres - set POSTGRES_PASSWORD=postgres to keep its data.}@db:5432/${POSTGRES_DB:-simstudio}' - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} # Derived from NEXT_PUBLIC_APP_URL, matching the simstudio service — a # single public-origin variable keeps the two from disagreeing. @@ -109,7 +109,7 @@ services: image: ghcr.io/simstudioai/migrations:${SIM_VERSION:-latest} working_dir: /app/packages/db environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio} + - 'DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?is required. For a new install set it in .env to the output of: openssl rand -hex 24. An install created before it was required uses the password postgres - set POSTGRES_PASSWORD=postgres to keep its data.}@db:5432/${POSTGRES_DB:-simstudio}' depends_on: db: condition: service_healthy @@ -151,14 +151,21 @@ services: simstudio: condition: service_healthy + # Not published to the host: app, realtime and migrations reach it over the + # Compose network as db:5432. A host `ports:` mapping binds every interface and + # Docker's own iptables rules bypass host firewalls, so it would put the + # database on the network. For host access (psql, a desktop client), use + # `docker compose exec db psql -U postgres simstudio`, or pass a second -f + # file that publishes '127.0.0.1:5432:5432' (-f disables override files). db: image: pgvector/pgvector:pg17 restart: unless-stopped - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + # Postgres applies this only when it creates the data volume; later + # changes are ignored. An install created before this was required got + # the password `postgres` — set exactly that to keep it working. + - 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?is required. For a new install set it in .env to the output of: openssl rand -hex 24. An install created before it was required uses the password postgres - set POSTGRES_PASSWORD=postgres to keep its data.}' - POSTGRES_DB=${POSTGRES_DB:-simstudio} volumes: - postgres_data:/var/lib/postgresql/data diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c862c419e99..83b606afee8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -280,8 +280,6 @@ async function main() { 'POSTGRES_DB=simstudio', '-v', `${dataDir}/postgres:/var/lib/postgresql/data`, - '-p', - '5432:5432', 'pgvector/pgvector:pg17', ]) diff --git a/packages/sim-setup/src/compose-database.test.ts b/packages/sim-setup/src/compose-database.test.ts new file mode 100644 index 00000000000..38143d43bd2 --- /dev/null +++ b/packages/sim-setup/src/compose-database.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + choosePostgresPassword, + composeFileRequiresPostgresPassword, + configuredPostgresPassword, + LEGACY_POSTGRES_PASSWORD, +} from './compose-database' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') +const COMPOSE_FILES = [ + 'docker-compose.prod.yml', + 'docker-compose.local.yml', + 'docker-compose.ollama.yml', +] as const + +function composePath(file: string): string { + return path.join(REPO_ROOT, file) +} + +/** The lines of one top-level service, up to the next service or top-level key. */ +function serviceBlock(file: string, service: string): string { + const lines = readFileSync(composePath(file), 'utf8').split('\n') + const start = lines.findIndex((line) => line === ` ${service}:`) + if (start === -1) throw new Error(`${file} has no ${service} service`) + const end = lines.findIndex((line, index) => index > start && /^ {0,2}[A-Za-z0-9_-]+:/.test(line)) + return lines.slice(start, end === -1 ? undefined : end).join('\n') +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('choosePostgresPassword', () => { + it('leaves a configured password alone without looking for a volume', () => { + const lookup = vi.fn(() => true) + expect(choosePostgresPassword('already-set', 'sim-abc', lookup)).toBeNull() + expect(lookup).not.toHaveBeenCalled() + }) + + it('generates a password for a project with no database volume yet', () => { + const lookup = vi.fn(() => false) + const choice = choosePostgresPassword(undefined, 'sim-abc', lookup) + expect(lookup).toHaveBeenCalledWith('sim-abc') + expect(choice?.legacy).toBe(false) + expect(choice?.value).toMatch(/^[0-9a-f]{64}$/) + }) + + it('keeps the legacy password for a volume created before it was required', () => { + const choice = choosePostgresPassword(undefined, 'sim-abc', () => true) + expect(choice).toEqual({ value: LEGACY_POSTGRES_PASSWORD, legacy: true }) + }) +}) + +describe('configuredPostgresPassword', () => { + it('prefers the shell environment, which Compose interpolates over .env', () => { + vi.stubEnv('POSTGRES_PASSWORD', 'from-shell') + expect(configuredPostgresPassword('from-env-file')).toBe('from-shell') + }) + + it('falls back to .env and treats empty values as unset', () => { + vi.stubEnv('POSTGRES_PASSWORD', '') + expect(configuredPostgresPassword('from-env-file')).toBe('from-env-file') + expect(configuredPostgresPassword('')).toBeUndefined() + expect(configuredPostgresPassword(undefined)).toBeUndefined() + }) +}) + +describe('shipped Compose files', () => { + it('only the production file requires POSTGRES_PASSWORD', () => { + expect(composeFileRequiresPostgresPassword(composePath('docker-compose.prod.yml'))).toBe(true) + expect(composeFileRequiresPostgresPassword(composePath('docker-compose.local.yml'))).toBe(false) + expect(composeFileRequiresPostgresPassword(composePath('docker-compose.ollama.yml'))).toBe( + false + ) + }) + + it('never falls back to a default password in the production file', () => { + const contents = readFileSync(composePath('docker-compose.prod.yml'), 'utf8') + const references = contents.match(/\$\{POSTGRES_PASSWORD[^}]*\}/g) ?? [] + expect(references).toHaveLength(4) + for (const reference of references) { + expect(reference.startsWith('${POSTGRES_PASSWORD:?')).toBe(true) + } + }) + + it.each(COMPOSE_FILES)('%s does not publish the database to the host', (file) => { + expect(serviceBlock(file, 'db')).not.toMatch(/^\s+ports:/m) + }) +}) diff --git a/packages/sim-setup/src/compose-database.ts b/packages/sim-setup/src/compose-database.ts new file mode 100644 index 00000000000..daa35b59db8 --- /dev/null +++ b/packages/sim-setup/src/compose-database.ts @@ -0,0 +1,121 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { generateSecret } from './env-files' +import { SetupError } from './errors' + +/** + * The password Postgres was initialized with on a Compose install created while + * `docker-compose.prod.yml` still defaulted it. Postgres applies + * `POSTGRES_PASSWORD` only when it creates the data volume, so such a volume + * keeps this password until it is rotated inside the database. + */ +export const LEGACY_POSTGRES_PASSWORD = 'postgres' + +/** The volume key the Sim Compose files declare for Postgres data. */ +const POSTGRES_VOLUME = 'postgres_data' + +/** + * Whether a Compose file refuses to start without `POSTGRES_PASSWORD`. Only the + * production file does, and the version that requires it is also the one that + * stops publishing the database, so older files and dev stacks are left alone. + */ +export function composeFileRequiresPostgresPassword(composeFile: string): boolean { + return readFileSync(composeFile, 'utf8').includes('${POSTGRES_PASSWORD:?') +} + +export interface PostgresPasswordChoice { + value: string + /** True when the value is the legacy password an existing volume was created with. */ + legacy: boolean +} + +/** + * Picks the `POSTGRES_PASSWORD` a Compose install's `.env` needs, or null when it + * already has one. The production Compose file requires the variable, so an + * install that never set it must gain one before Compose will start — and the + * value must match what the data volume was created with: a fresh password for + * a new volume, the legacy one for a volume that already exists. Guessing wrong + * in the other direction would lock the app out of its own database. + */ +export function choosePostgresPassword( + configured: string | undefined, + project: string, + hasDatabaseVolume: (project: string) => boolean = composeDatabaseVolumeExists +): PostgresPasswordChoice | null { + if (configured) return null + return hasDatabaseVolume(project) + ? { value: LEGACY_POSTGRES_PASSWORD, legacy: true } + : { value: generateSecret(), legacy: false } +} + +/** + * The value Compose will interpolate for `POSTGRES_PASSWORD`: the shell + * environment wins over `.env`, so a value exported there is already in effect. + */ +export function configuredPostgresPassword(envFileValue: string | undefined): string | undefined { + return process.env.POSTGRES_PASSWORD || envFileValue || undefined +} + +/** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */ +export function composeDatabaseVolumeExists(project: string): boolean { + const result = spawnSync( + 'docker', + [ + 'volume', + 'ls', + '-q', + '--filter', + `label=com.docker.compose.project=${project}`, + '--filter', + `label=com.docker.compose.volume=${POSTGRES_VOLUME}`, + ], + { encoding: 'utf8' } + ) + if (result.status !== 0) { + throw new SetupError( + `could not check for an existing database volume: ${result.stderr.trim() || result.stdout.trim()}` + ) + } + return result.stdout.trim().length > 0 +} + +/** + * The project name Compose resolves for a file run from `cwd` — from `-p`, + * `COMPOSE_PROJECT_NAME`, or the directory. Read without interpolation, so it + * works before the required variables exist. + */ +export function composeProjectName(composeFile: string, cwd: string): string { + const result = spawnSync( + 'docker', + ['compose', '-f', composeFile, 'config', '--no-interpolate', '--format', 'json'], + { cwd, encoding: 'utf8' } + ) + const name = result.status === 0 ? parseProjectName(result.stdout) : null + if (name) return name + throw new SetupError( + `could not resolve the Compose project name for ${composeFile}: ${result.stderr.trim() || 'no name in docker compose config'}` + ) +} + +function parseProjectName(stdout: string): string | null { + try { + const { name } = JSON.parse(stdout) as { name?: unknown } + return typeof name === 'string' && name ? name : null + } catch { + return null + } +} + +/** + * Explains why the legacy password was kept and how to rotate it. `compose` is + * the pinned `docker compose -p … -f …` prefix for this install. + */ +export function legacyPostgresPasswordNote(compose: string): string { + return [ + 'This database was created with the password "postgres", so .env now sets', + 'POSTGRES_PASSWORD=postgres to keep it working. The database is not published', + 'to the host, so only the containers in this stack can reach it. To rotate it:', + ` ${compose} exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD ''"`, + ' then set POSTGRES_PASSWORD= in .env and run: npx sim-setup start', + ].join('\n') +} diff --git a/packages/sim-setup/src/lifecycle.ts b/packages/sim-setup/src/lifecycle.ts index e7c2128c0c4..ed8efa12563 100644 --- a/packages/sim-setup/src/lifecycle.ts +++ b/packages/sim-setup/src/lifecycle.ts @@ -2,10 +2,16 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { ensureProductionComposeFile } from './compose-asset' +import { + choosePostgresPassword, + composeFileRequiresPostgresPassword, + configuredPostgresPassword, + legacyPostgresPasswordNote, +} from './compose-database' import { legacyComposeProjectName } from './compose-project' import { directoryOverride, resolveSetupContextAtRoot, SETUP_CONTEXT } from './context' import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect' -import { archiveEnvFile, archiveFile, parseEnv, ROOT } from './env-files' +import { archiveEnvFile, archiveFile, parseEnv, ROOT, upsertEnv, writeEnvFile } from './env-files' import { SetupError } from './errors' import { forwardCommands, isLocalKubeContext } from './modes/k8s' import { httpHealth } from './probes' @@ -196,6 +202,30 @@ function composeArgs(install: ComposeInstall, ...verb: string[]): string[] { return ['compose', '-p', install.project, '-f', install.file, ...verb] } +/** + * Gives a Compose install the `POSTGRES_PASSWORD` its file requires before the + * stack is brought up, matching whatever its data volume was created with. + */ +function ensureComposePostgresPassword(install: ComposeInstall): void { + if (!composeFileRequiresPostgresPassword(install.file)) return + const envPath = path.join(install.dir, '.env') + const content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '' + const choice = choosePostgresPassword( + configuredPostgresPassword(parseEnv(content).get('POSTGRES_PASSWORD')), + install.project + ) + if (!choice) return + writeEnvFile(envPath, upsertEnv(content, 'POSTGRES_PASSWORD', choice.value)) + if (choice.legacy) { + p.note( + legacyPostgresPasswordNote(`docker compose -p ${install.project} -f ${install.file}`), + 'Database password' + ) + } else { + p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`) + } +} + /** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */ function devInstall(detection: Detection): DevInstall | null { const postgres = detection.dbContainer?.managed ?? false @@ -294,6 +324,7 @@ function k8sReachHints(context: string): string { function start(install: Install): void { if (install.kind === 'compose') { + ensureComposePostgresPassword(install) const spin = p.spinner() spin.start('Starting containers…') dockerRun(composeArgs(install, 'up', '-d'), 'docker compose up failed', install.dir) @@ -358,6 +389,7 @@ function stop(install: Install): void { function restart(install: Install): void { if (install.kind === 'compose') { + ensureComposePostgresPassword(install) const spin = p.spinner() spin.start('Restarting containers…') dockerRun(composeArgs(install, 'restart'), 'docker compose restart failed', install.dir) @@ -404,9 +436,10 @@ function update(install: Install): void { } const mode = getComposeUpdateMode(install.file) + if (mode === 'pull') install.file = refreshComposeFileForUpdate(install.file, install.dir) + ensureComposePostgresPassword(install) const spin = p.spinner() if (mode === 'pull') { - install.file = refreshComposeFileForUpdate(install.file, install.dir) spin.start('Pulling configured Sim images…') dockerRun(composeArgs(install, 'pull'), 'docker compose pull failed', install.dir) } else { diff --git a/packages/sim-setup/src/modes/compose.ts b/packages/sim-setup/src/modes/compose.ts index 4f05cc37a6e..b10eb1221d9 100644 --- a/packages/sim-setup/src/modes/compose.ts +++ b/packages/sim-setup/src/modes/compose.ts @@ -3,6 +3,13 @@ import path from 'node:path' import { EMAIL_SETUP, STORAGE_SETUP } from '../capability-config' import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup' import { ensureProductionComposeFile } from '../compose-asset' +import { + choosePostgresPassword, + composeFileRequiresPostgresPassword, + composeProjectName, + configuredPostgresPassword, + legacyPostgresPasswordNote, +} from '../compose-database' import { legacyComposeProjectName, standaloneComposeProjectName } from '../compose-project' import { SETUP_CONTEXT } from '../context' import type { Detection } from '../detect' @@ -179,6 +186,13 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom if (composeProject && !configuredComposeProject) { values.COMPOSE_PROJECT_NAME = composeProject } + const postgresPassword = composeFileRequiresPostgresPassword(composeFile) + ? choosePostgresPassword( + configuredPostgresPassword(root.vars.get('POSTGRES_PASSWORD')), + composeProject ?? composeProjectName(composeFile, ROOT) + ) + : null + if (postgresPassword) values.POSTGRES_PASSWORD = postgresPassword.value // Before the key is minted: a half-set override mints against one environment // and validates against the other, and warning afterwards is too late — the // bad key is already stored, and the next run offers to keep it. @@ -218,6 +232,14 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom for (const key of Object.keys(values)) remove.delete(key) reconcileEnvValues('root', [...remove], values) p.log.step('Wrote .env (compose reads it for variable substitution)') + if (postgresPassword?.legacy) { + p.note( + legacyPostgresPasswordNote(composeCommand(composeFile, composeProject)), + 'Database password' + ) + } else if (postgresPassword) { + p.log.step('Generated POSTGRES_PASSWORD') + } const validation = spawnSync('docker', composeArgs(composeFile, composeProject, 'config'), { cwd: ROOT, From 991d081ecb0dae98100670c2c0822a8a140d91bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 14:19:47 -0700 Subject: [PATCH 2/6] fix(sim-setup): persist a shell POSTGRES_PASSWORD and rotate the effective role A password exported only in the shell is now written to .env, so a later run without the export does not fall back to the legacy value. Rotation steps use the install's POSTGRES_USER and ALTER ROLE CURRENT_USER. --- .../docs/platform/self-hosting/security.mdx | 2 +- .../sim-setup/src/compose-database.test.ts | 62 ++++++++----- packages/sim-setup/src/compose-database.ts | 91 ++++++++++++------- packages/sim-setup/src/lifecycle.ts | 23 ++--- packages/sim-setup/src/modes/compose.ts | 19 ++-- 5 files changed, 120 insertions(+), 77 deletions(-) diff --git a/apps/docs/content/docs/platform/self-hosting/security.mdx b/apps/docs/content/docs/platform/self-hosting/security.mdx index dd2b1f596f0..a60da61710a 100644 --- a/apps/docs/content/docs/platform/self-hosting/security.mdx +++ b/apps/docs/content/docs/platform/self-hosting/security.mdx @@ -279,7 +279,7 @@ The Compose files do not publish the `db` service to the host: `simstudio`, `rea Installs created from an earlier Compose file published the database on every interface of the host (`5432:5432`), and a `POSTGRES_PASSWORD` left unset fell back to `postgres`. A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. Update the Compose file, then check what your database was created with: - **You set `POSTGRES_PASSWORD` before the first start.** Nothing else to do — updating the file closes the port. - - **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD ''"`, set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out. + - **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE CURRENT_USER PASSWORD ''"` (with your `POSTGRES_USER` in place of `postgres` if you set one), set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out. `npx sim-setup start` and `npx sim-setup update` write the right value for you and print the rotation steps. diff --git a/packages/sim-setup/src/compose-database.test.ts b/packages/sim-setup/src/compose-database.test.ts index 38143d43bd2..4ba4d607887 100644 --- a/packages/sim-setup/src/compose-database.test.ts +++ b/packages/sim-setup/src/compose-database.test.ts @@ -5,8 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { choosePostgresPassword, composeFileRequiresPostgresPassword, - configuredPostgresPassword, LEGACY_POSTGRES_PASSWORD, + postgresUser, } from './compose-database' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') @@ -34,37 +34,57 @@ afterEach(() => { }) describe('choosePostgresPassword', () => { - it('leaves a configured password alone without looking for a volume', () => { - const lookup = vi.fn(() => true) - expect(choosePostgresPassword('already-set', 'sim-abc', lookup)).toBeNull() - expect(lookup).not.toHaveBeenCalled() + it('leaves a password already in .env alone without looking for a volume', () => { + const hasDatabaseVolume = vi.fn(() => true) + expect( + choosePostgresPassword('in-env-file', 'sim-abc', { + shellValue: 'in-shell', + hasDatabaseVolume, + }) + ).toBeNull() + expect(hasDatabaseVolume).not.toHaveBeenCalled() + }) + + it('persists a shell-exported password, which is what Compose is using', () => { + const hasDatabaseVolume = vi.fn(() => true) + expect( + choosePostgresPassword(undefined, 'sim-abc', { shellValue: 'in-shell', hasDatabaseVolume }) + ).toEqual({ value: 'in-shell', source: 'environment' }) + expect(hasDatabaseVolume).not.toHaveBeenCalled() }) it('generates a password for a project with no database volume yet', () => { - const lookup = vi.fn(() => false) - const choice = choosePostgresPassword(undefined, 'sim-abc', lookup) - expect(lookup).toHaveBeenCalledWith('sim-abc') - expect(choice?.legacy).toBe(false) + const hasDatabaseVolume = vi.fn(() => false) + const choice = choosePostgresPassword('', 'sim-abc', { shellValue: '', hasDatabaseVolume }) + expect(hasDatabaseVolume).toHaveBeenCalledWith('sim-abc') + expect(choice?.source).toBe('generated') expect(choice?.value).toMatch(/^[0-9a-f]{64}$/) }) it('keeps the legacy password for a volume created before it was required', () => { - const choice = choosePostgresPassword(undefined, 'sim-abc', () => true) - expect(choice).toEqual({ value: LEGACY_POSTGRES_PASSWORD, legacy: true }) + expect( + choosePostgresPassword(undefined, 'sim-abc', { + shellValue: '', + hasDatabaseVolume: () => true, + }) + ).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }) }) -}) -describe('configuredPostgresPassword', () => { - it('prefers the shell environment, which Compose interpolates over .env', () => { - vi.stubEnv('POSTGRES_PASSWORD', 'from-shell') - expect(configuredPostgresPassword('from-env-file')).toBe('from-shell') + it('reads the shell environment by default', () => { + vi.stubEnv('POSTGRES_PASSWORD', 'from-process') + expect(choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true })).toEqual( + { value: 'from-process', source: 'environment' } + ) }) +}) - it('falls back to .env and treats empty values as unset', () => { - vi.stubEnv('POSTGRES_PASSWORD', '') - expect(configuredPostgresPassword('from-env-file')).toBe('from-env-file') - expect(configuredPostgresPassword('')).toBeUndefined() - expect(configuredPostgresPassword(undefined)).toBeUndefined() +describe('postgresUser', () => { + it('prefers the shell, then .env, then the image default', () => { + vi.stubEnv('POSTGRES_USER', 'from-shell') + expect(postgresUser('from-env-file')).toBe('from-shell') + vi.stubEnv('POSTGRES_USER', '') + expect(postgresUser('from-env-file')).toBe('from-env-file') + expect(postgresUser(undefined)).toBe('postgres') }) }) diff --git a/packages/sim-setup/src/compose-database.ts b/packages/sim-setup/src/compose-database.ts index daa35b59db8..b117220b4f7 100644 --- a/packages/sim-setup/src/compose-database.ts +++ b/packages/sim-setup/src/compose-database.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { generateSecret } from './env-files' import { SetupError } from './errors' +import * as p from './prompter' /** * The password Postgres was initialized with on a Compose install created while @@ -23,37 +24,45 @@ export function composeFileRequiresPostgresPassword(composeFile: string): boolea return readFileSync(composeFile, 'utf8').includes('${POSTGRES_PASSWORD:?') } +/** Where a chosen `POSTGRES_PASSWORD` came from. */ +export type PostgresPasswordSource = 'environment' | 'generated' | 'legacy' + export interface PostgresPasswordChoice { value: string - /** True when the value is the legacy password an existing volume was created with. */ - legacy: boolean + source: PostgresPasswordSource +} + +interface ChooseOptions { + /** `POSTGRES_PASSWORD` from the shell, which Compose interpolates over `.env`. */ + shellValue?: string + hasDatabaseVolume?: (project: string) => boolean } /** - * Picks the `POSTGRES_PASSWORD` a Compose install's `.env` needs, or null when it - * already has one. The production Compose file requires the variable, so an - * install that never set it must gain one before Compose will start — and the - * value must match what the data volume was created with: a fresh password for - * a new volume, the legacy one for a volume that already exists. Guessing wrong - * in the other direction would lock the app out of its own database. + * Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null + * when `.env` already has one. The production Compose file requires the + * variable, and the value must match what the data volume was created with — + * Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a + * wrong value locks the app out of its own database: + * + * - A value exported in the shell is what Compose is using, so it is persisted; + * otherwise a later run without the export would fall back to a guess. + * - With no value anywhere, an existing volume was created with the legacy + * password, and a project with no volume yet gets a generated one. */ export function choosePostgresPassword( - configured: string | undefined, + envFileValue: string | undefined, project: string, - hasDatabaseVolume: (project: string) => boolean = composeDatabaseVolumeExists + { + shellValue = process.env.POSTGRES_PASSWORD, + hasDatabaseVolume = composeDatabaseVolumeExists, + }: ChooseOptions = {} ): PostgresPasswordChoice | null { - if (configured) return null + if (envFileValue) return null + if (shellValue) return { value: shellValue, source: 'environment' } return hasDatabaseVolume(project) - ? { value: LEGACY_POSTGRES_PASSWORD, legacy: true } - : { value: generateSecret(), legacy: false } -} - -/** - * The value Compose will interpolate for `POSTGRES_PASSWORD`: the shell - * environment wins over `.env`, so a value exported there is already in effect. - */ -export function configuredPostgresPassword(envFileValue: string | undefined): string | undefined { - return process.env.POSTGRES_PASSWORD || envFileValue || undefined + ? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' } + : { value: generateSecret(), source: 'generated' } } /** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */ @@ -107,15 +116,35 @@ function parseProjectName(stdout: string): string | null { } /** - * Explains why the legacy password was kept and how to rotate it. `compose` is - * the pinned `docker compose -p … -f …` prefix for this install. + * Reports what was written. `compose` is the pinned `docker compose -p … -f …` + * prefix for the install, and `user` the effective `POSTGRES_USER`, both used + * in the legacy rotation steps. */ -export function legacyPostgresPasswordNote(compose: string): string { - return [ - 'This database was created with the password "postgres", so .env now sets', - 'POSTGRES_PASSWORD=postgres to keep it working. The database is not published', - 'to the host, so only the containers in this stack can reach it. To rotate it:', - ` ${compose} exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD ''"`, - ' then set POSTGRES_PASSWORD= in .env and run: npx sim-setup start', - ].join('\n') +export function reportPostgresPasswordChoice( + choice: PostgresPasswordChoice, + { compose, user, envPath }: { compose: string; user: string; envPath: string } +): void { + if (choice.source === 'environment') { + p.log.step(`Saved POSTGRES_PASSWORD from the shell environment to ${envPath}`) + return + } + if (choice.source === 'generated') { + p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`) + return + } + p.note( + [ + `This database was created with the password "${LEGACY_POSTGRES_PASSWORD}", so ${envPath}`, + `now sets POSTGRES_PASSWORD=${LEGACY_POSTGRES_PASSWORD} to keep it working. The database is not`, + 'published to the host, so only the containers in this stack can reach it. To rotate it:', + ` ${compose} exec db psql -U ${user} -c "ALTER ROLE CURRENT_USER PASSWORD ''"`, + ' then set POSTGRES_PASSWORD= in .env and run: npx sim-setup start', + ].join('\n'), + 'Database password' + ) +} + +/** The Postgres role Compose initializes, which the shell overrides over `.env` like any variable. */ +export function postgresUser(envFileValue: string | undefined): string { + return process.env.POSTGRES_USER || envFileValue || 'postgres' } diff --git a/packages/sim-setup/src/lifecycle.ts b/packages/sim-setup/src/lifecycle.ts index ed8efa12563..ddf3047b7c1 100644 --- a/packages/sim-setup/src/lifecycle.ts +++ b/packages/sim-setup/src/lifecycle.ts @@ -5,8 +5,8 @@ import { ensureProductionComposeFile } from './compose-asset' import { choosePostgresPassword, composeFileRequiresPostgresPassword, - configuredPostgresPassword, - legacyPostgresPasswordNote, + postgresUser, + reportPostgresPasswordChoice, } from './compose-database' import { legacyComposeProjectName } from './compose-project' import { directoryOverride, resolveSetupContextAtRoot, SETUP_CONTEXT } from './context' @@ -210,20 +210,15 @@ function ensureComposePostgresPassword(install: ComposeInstall): void { if (!composeFileRequiresPostgresPassword(install.file)) return const envPath = path.join(install.dir, '.env') const content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '' - const choice = choosePostgresPassword( - configuredPostgresPassword(parseEnv(content).get('POSTGRES_PASSWORD')), - install.project - ) + const vars = parseEnv(content) + const choice = choosePostgresPassword(vars.get('POSTGRES_PASSWORD'), install.project) if (!choice) return writeEnvFile(envPath, upsertEnv(content, 'POSTGRES_PASSWORD', choice.value)) - if (choice.legacy) { - p.note( - legacyPostgresPasswordNote(`docker compose -p ${install.project} -f ${install.file}`), - 'Database password' - ) - } else { - p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`) - } + reportPostgresPasswordChoice(choice, { + compose: `docker compose -p ${install.project} -f ${install.file}`, + user: postgresUser(vars.get('POSTGRES_USER')), + envPath, + }) } /** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */ diff --git a/packages/sim-setup/src/modes/compose.ts b/packages/sim-setup/src/modes/compose.ts index b10eb1221d9..0b9792c6204 100644 --- a/packages/sim-setup/src/modes/compose.ts +++ b/packages/sim-setup/src/modes/compose.ts @@ -7,8 +7,8 @@ import { choosePostgresPassword, composeFileRequiresPostgresPassword, composeProjectName, - configuredPostgresPassword, - legacyPostgresPasswordNote, + postgresUser, + reportPostgresPasswordChoice, } from '../compose-database' import { legacyComposeProjectName, standaloneComposeProjectName } from '../compose-project' import { SETUP_CONTEXT } from '../context' @@ -188,7 +188,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom } const postgresPassword = composeFileRequiresPostgresPassword(composeFile) ? choosePostgresPassword( - configuredPostgresPassword(root.vars.get('POSTGRES_PASSWORD')), + root.vars.get('POSTGRES_PASSWORD'), composeProject ?? composeProjectName(composeFile, ROOT) ) : null @@ -232,13 +232,12 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom for (const key of Object.keys(values)) remove.delete(key) reconcileEnvValues('root', [...remove], values) p.log.step('Wrote .env (compose reads it for variable substitution)') - if (postgresPassword?.legacy) { - p.note( - legacyPostgresPasswordNote(composeCommand(composeFile, composeProject)), - 'Database password' - ) - } else if (postgresPassword) { - p.log.step('Generated POSTGRES_PASSWORD') + if (postgresPassword) { + reportPostgresPasswordChoice(postgresPassword, { + compose: composeCommand(composeFile, composeProject), + user: postgresUser(root.vars.get('POSTGRES_USER')), + envPath: root.path, + }) } const validation = spawnSync('docker', composeArgs(composeFile, composeProject, 'config'), { From d4ee33766d9959a6e47f18b642bcb144bb99d057 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 14:31:19 -0700 Subject: [PATCH 3/6] fix(sim-setup): refuse an ambiguous or unstorable shell POSTGRES_PASSWORD An empty export, one that differs from .env, or one .env cannot hold verbatim now stops setup with instructions instead of silently picking a value the database may not have been created with. --- .../sim-setup/src/compose-database.test.ts | 69 ++++++++++++++----- packages/sim-setup/src/compose-database.ts | 65 +++++++++++++---- 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/sim-setup/src/compose-database.test.ts b/packages/sim-setup/src/compose-database.test.ts index 4ba4d607887..caca8692afe 100644 --- a/packages/sim-setup/src/compose-database.test.ts +++ b/packages/sim-setup/src/compose-database.test.ts @@ -8,6 +8,7 @@ import { LEGACY_POSTGRES_PASSWORD, postgresUser, } from './compose-database' +import { SetupError } from './errors' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') const COMPOSE_FILES = [ @@ -34,43 +35,77 @@ afterEach(() => { }) describe('choosePostgresPassword', () => { + const noShell = {} + it('leaves a password already in .env alone without looking for a volume', () => { const hasDatabaseVolume = vi.fn(() => true) expect( - choosePostgresPassword('in-env-file', 'sim-abc', { - shellValue: 'in-shell', - hasDatabaseVolume, - }) + choosePostgresPassword('in-env-file', 'sim-abc', { shell: noShell, hasDatabaseVolume }) ).toBeNull() expect(hasDatabaseVolume).not.toHaveBeenCalled() }) - it('persists a shell-exported password, which is what Compose is using', () => { - const hasDatabaseVolume = vi.fn(() => true) - expect( - choosePostgresPassword(undefined, 'sim-abc', { shellValue: 'in-shell', hasDatabaseVolume }) - ).toEqual({ value: 'in-shell', source: 'environment' }) - expect(hasDatabaseVolume).not.toHaveBeenCalled() - }) - it('generates a password for a project with no database volume yet', () => { const hasDatabaseVolume = vi.fn(() => false) - const choice = choosePostgresPassword('', 'sim-abc', { shellValue: '', hasDatabaseVolume }) + const choice = choosePostgresPassword(undefined, 'sim-abc', { + shell: noShell, + hasDatabaseVolume, + }) expect(hasDatabaseVolume).toHaveBeenCalledWith('sim-abc') expect(choice?.source).toBe('generated') expect(choice?.value).toMatch(/^[0-9a-f]{64}$/) }) it('keeps the legacy password for a volume created before it was required', () => { + expect( + choosePostgresPassword('', 'sim-abc', { shell: noShell, hasDatabaseVolume: () => true }) + ).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }) + }) + + it('persists a shell-only password, which is what Compose is using', () => { + const hasDatabaseVolume = vi.fn(() => true) expect( choosePostgresPassword(undefined, 'sim-abc', { - shellValue: '', - hasDatabaseVolume: () => true, + shell: { POSTGRES_PASSWORD: 'in-shell' }, + hasDatabaseVolume, }) - ).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }) + ).toEqual({ value: 'in-shell', source: 'environment' }) + expect(hasDatabaseVolume).not.toHaveBeenCalled() + }) + + it('accepts a shell password that matches .env', () => { + expect( + choosePostgresPassword('same', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'same' } }) + ).toBeNull() }) - it('reads the shell environment by default', () => { + it('refuses a shell password that differs from .env', () => { + expect(() => + choosePostgresPassword('in-env-file', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'other' } }) + ).toThrow(SetupError) + }) + + it('refuses an empty shell export, which Compose would use over .env', () => { + for (const envFileValue of [undefined, 'in-env-file']) { + expect(() => + choosePostgresPassword(envFileValue, 'sim-abc', { shell: { POSTGRES_PASSWORD: '' } }) + ).toThrow(/exported but empty/) + } + }) + + it.each(['pa ss', 'pass#word', 'pa"ss', "pa'ss", 'pa\\ss', 'pa$ss'])( + 'refuses to persist %s, which .env cannot store verbatim', + (value) => { + expect(() => + choosePostgresPassword(undefined, 'sim-abc', { + shell: { POSTGRES_PASSWORD: value }, + hasDatabaseVolume: () => false, + }) + ).toThrow(/cannot store verbatim/) + } + ) + + it('reads the process environment by default', () => { vi.stubEnv('POSTGRES_PASSWORD', 'from-process') expect(choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true })).toEqual( { value: 'from-process', source: 'environment' } diff --git a/packages/sim-setup/src/compose-database.ts b/packages/sim-setup/src/compose-database.ts index b117220b4f7..20ecb281f8d 100644 --- a/packages/sim-setup/src/compose-database.ts +++ b/packages/sim-setup/src/compose-database.ts @@ -32,37 +32,72 @@ export interface PostgresPasswordChoice { source: PostgresPasswordSource } +/** + * Characters that `.env` would reinterpret — whitespace, comments, quotes, + * escapes, and Compose's `$` interpolation — so a value containing one cannot + * be written unquoted and read back unchanged. + */ +const DOTENV_UNSAFE = /[\s#'"\\$]/ + interface ChooseOptions { - /** `POSTGRES_PASSWORD` from the shell, which Compose interpolates over `.env`. */ - shellValue?: string + /** The shell environment, whose `POSTGRES_PASSWORD` Compose interpolates over `.env`. */ + shell?: NodeJS.ProcessEnv hasDatabaseVolume?: (project: string) => boolean } /** * Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null - * when `.env` already has one. The production Compose file requires the - * variable, and the value must match what the data volume was created with — + * when `.env` already has the right one. The production Compose file requires + * the variable, and the value must match what the data volume was created with — * Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a * wrong value locks the app out of its own database: * - * - A value exported in the shell is what Compose is using, so it is persisted; - * otherwise a later run without the export would fall back to a guess. + * - A value exported in the shell is what Compose is using. It is persisted when + * `.env` has none, so a later run without the export does not fall back to a + * guess. An empty export, one that differs from `.env`, or one `.env` cannot + * hold verbatim is refused: which value the volume was created with cannot be + * known, and either silent choice can lock the app out. * - With no value anywhere, an existing volume was created with the legacy * password, and a project with no volume yet gets a generated one. */ export function choosePostgresPassword( envFileValue: string | undefined, project: string, - { - shellValue = process.env.POSTGRES_PASSWORD, - hasDatabaseVolume = composeDatabaseVolumeExists, - }: ChooseOptions = {} + { shell = process.env, hasDatabaseVolume = composeDatabaseVolumeExists }: ChooseOptions = {} ): PostgresPasswordChoice | null { - if (envFileValue) return null - if (shellValue) return { value: shellValue, source: 'environment' } - return hasDatabaseVolume(project) - ? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' } - : { value: generateSecret(), source: 'generated' } + const shellValue = shell.POSTGRES_PASSWORD + if (shellValue === undefined) { + if (envFileValue) return null + return hasDatabaseVolume(project) + ? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' } + : { value: generateSecret(), source: 'generated' } + } + if (shellValue === '') { + throw new SetupError( + 'POSTGRES_PASSWORD is exported but empty, and Compose uses it over .env.', + ['unset it (unset POSTGRES_PASSWORD) so the value in .env applies'] + ) + } + if (envFileValue) { + if (envFileValue === shellValue) return null + throw new SetupError( + 'POSTGRES_PASSWORD in the shell differs from the one in .env, so it is unclear which one the database was created with.', + [ + 'unset the exported POSTGRES_PASSWORD if .env holds the database password', + 'or set the exported value in .env if that is the database password', + ] + ) + } + if (DOTENV_UNSAFE.test(shellValue)) { + throw new SetupError( + 'POSTGRES_PASSWORD is exported only in the shell, and contains characters .env cannot store verbatim.', + [ + 'add it to .env yourself, quoted, so later runs without the export still use it', + 'new installs: use a value from openssl rand -hex 24', + ] + ) + } + return { value: shellValue, source: 'environment' } } /** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */ From ffc77b7450c6165d65134c676321918a700c43d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 14:42:14 -0700 Subject: [PATCH 4/6] fix(sim-setup): only persist URL-safe shell passwords; honor an empty POSTGRES_USER export DATABASE_URL embeds the password unescaped, so a shell-only password is persisted only when it is made of URL-unreserved characters. An empty POSTGRES_USER export resolves to the Compose default, matching ${POSTGRES_USER:-postgres}. --- .../sim-setup/src/compose-database.test.ts | 41 +++++++++++++------ packages/sim-setup/src/compose-database.ts | 24 ++++++----- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/packages/sim-setup/src/compose-database.test.ts b/packages/sim-setup/src/compose-database.test.ts index caca8692afe..6f5069c1712 100644 --- a/packages/sim-setup/src/compose-database.test.ts +++ b/packages/sim-setup/src/compose-database.test.ts @@ -93,17 +93,32 @@ describe('choosePostgresPassword', () => { } }) - it.each(['pa ss', 'pass#word', 'pa"ss', "pa'ss", 'pa\\ss', 'pa$ss'])( - 'refuses to persist %s, which .env cannot store verbatim', - (value) => { - expect(() => - choosePostgresPassword(undefined, 'sim-abc', { - shell: { POSTGRES_PASSWORD: value }, - hasDatabaseVolume: () => false, - }) - ).toThrow(/cannot store verbatim/) - } - ) + it.each([ + 'pa ss', + 'pass#word', + 'pa"ss', + "pa'ss", + 'pa\\ss', + 'pa$ss', + 'pa/ss', + 'pa?ss', + 'pa%ss', + 'pa@ss', + 'pa:ss', + ])('refuses to persist %s, which .env or DATABASE_URL would change', (value) => { + expect(() => + choosePostgresPassword(undefined, 'sim-abc', { + shell: { POSTGRES_PASSWORD: value }, + hasDatabaseVolume: () => false, + }) + ).toThrow(SetupError) + }) + + it('persists a shell password made of URL-unreserved characters', () => { + expect( + choosePostgresPassword(undefined, 'sim-abc', { shell: { POSTGRES_PASSWORD: 'Ab9._~-z' } }) + ).toEqual({ value: 'Ab9._~-z', source: 'environment' }) + }) it('reads the process environment by default', () => { vi.stubEnv('POSTGRES_PASSWORD', 'from-process') @@ -114,10 +129,12 @@ describe('choosePostgresPassword', () => { }) describe('postgresUser', () => { - it('prefers the shell, then .env, then the image default', () => { + it('follows Compose: a shell export wins even when empty, then .env, then the default', () => { vi.stubEnv('POSTGRES_USER', 'from-shell') expect(postgresUser('from-env-file')).toBe('from-shell') vi.stubEnv('POSTGRES_USER', '') + expect(postgresUser('from-env-file')).toBe('postgres') + vi.stubEnv('POSTGRES_USER', undefined) expect(postgresUser('from-env-file')).toBe('from-env-file') expect(postgresUser(undefined)).toBe('postgres') }) diff --git a/packages/sim-setup/src/compose-database.ts b/packages/sim-setup/src/compose-database.ts index 20ecb281f8d..da42b92a1bf 100644 --- a/packages/sim-setup/src/compose-database.ts +++ b/packages/sim-setup/src/compose-database.ts @@ -33,11 +33,11 @@ export interface PostgresPasswordChoice { } /** - * Characters that `.env` would reinterpret — whitespace, comments, quotes, - * escapes, and Compose's `$` interpolation — so a value containing one cannot - * be written unquoted and read back unchanged. + * A password the wizard can persist verbatim: Compose interpolates it unescaped + * into `DATABASE_URL`, and `.env` reinterprets whitespace, comments, quotes and + * `$`, so only URL-unreserved characters survive both unchanged. */ -const DOTENV_UNSAFE = /[\s#'"\\$]/ +const PERSISTABLE_PASSWORD = /^[A-Za-z0-9._~-]+$/ interface ChooseOptions { /** The shell environment, whose `POSTGRES_PASSWORD` Compose interpolates over `.env`. */ @@ -88,12 +88,12 @@ export function choosePostgresPassword( ] ) } - if (DOTENV_UNSAFE.test(shellValue)) { + if (!PERSISTABLE_PASSWORD.test(shellValue)) { throw new SetupError( - 'POSTGRES_PASSWORD is exported only in the shell, and contains characters .env cannot store verbatim.', + 'POSTGRES_PASSWORD is exported only in the shell, and contains characters that cannot be stored in .env and embedded in DATABASE_URL unchanged.', [ - 'add it to .env yourself, quoted, so later runs without the export still use it', - 'new installs: use a value from openssl rand -hex 24', + 'use only letters, digits and . _ ~ - (for a new install: openssl rand -hex 24)', + 'or add it to .env yourself if you have confirmed it works in a connection URL', ] ) } @@ -179,7 +179,11 @@ export function reportPostgresPasswordChoice( ) } -/** The Postgres role Compose initializes, which the shell overrides over `.env` like any variable. */ +/** + * The Postgres role Compose initializes from `${POSTGRES_USER:-postgres}`. A shell + * export overrides `.env` even when empty, and an empty value takes the default. + */ export function postgresUser(envFileValue: string | undefined): string { - return process.env.POSTGRES_USER || envFileValue || 'postgres' + const shellUser = process.env.POSTGRES_USER + return shellUser === undefined ? envFileValue || 'postgres' : shellUser || 'postgres' } From f2c19227e7dd5dabe88548b3ef793822c93079d2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 15:45:53 -0700 Subject: [PATCH 5/6] fix(sim-setup): leave a shell-exported POSTGRES_PASSWORD to the operator Compose interpolates a shell value over .env, so the wizard no longer copies it into .env and no longer needs rules for values .env or DATABASE_URL would change. An empty export is still refused, since Compose would use it and refuse to start. --- .../sim-setup/src/compose-database.test.ts | 50 ++------------- packages/sim-setup/src/compose-database.ts | 61 +++++-------------- 2 files changed, 19 insertions(+), 92 deletions(-) diff --git a/packages/sim-setup/src/compose-database.test.ts b/packages/sim-setup/src/compose-database.test.ts index 6f5069c1712..07cdba3c390 100644 --- a/packages/sim-setup/src/compose-database.test.ts +++ b/packages/sim-setup/src/compose-database.test.ts @@ -8,7 +8,6 @@ import { LEGACY_POSTGRES_PASSWORD, postgresUser, } from './compose-database' -import { SetupError } from './errors' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') const COMPOSE_FILES = [ @@ -62,27 +61,15 @@ describe('choosePostgresPassword', () => { ).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }) }) - it('persists a shell-only password, which is what Compose is using', () => { + it('leaves a shell-exported password to the operator, since Compose uses it', () => { const hasDatabaseVolume = vi.fn(() => true) expect( choosePostgresPassword(undefined, 'sim-abc', { shell: { POSTGRES_PASSWORD: 'in-shell' }, hasDatabaseVolume, }) - ).toEqual({ value: 'in-shell', source: 'environment' }) - expect(hasDatabaseVolume).not.toHaveBeenCalled() - }) - - it('accepts a shell password that matches .env', () => { - expect( - choosePostgresPassword('same', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'same' } }) ).toBeNull() - }) - - it('refuses a shell password that differs from .env', () => { - expect(() => - choosePostgresPassword('in-env-file', 'sim-abc', { shell: { POSTGRES_PASSWORD: 'other' } }) - ).toThrow(SetupError) + expect(hasDatabaseVolume).not.toHaveBeenCalled() }) it('refuses an empty shell export, which Compose would use over .env', () => { @@ -93,38 +80,11 @@ describe('choosePostgresPassword', () => { } }) - it.each([ - 'pa ss', - 'pass#word', - 'pa"ss', - "pa'ss", - 'pa\\ss', - 'pa$ss', - 'pa/ss', - 'pa?ss', - 'pa%ss', - 'pa@ss', - 'pa:ss', - ])('refuses to persist %s, which .env or DATABASE_URL would change', (value) => { - expect(() => - choosePostgresPassword(undefined, 'sim-abc', { - shell: { POSTGRES_PASSWORD: value }, - hasDatabaseVolume: () => false, - }) - ).toThrow(SetupError) - }) - - it('persists a shell password made of URL-unreserved characters', () => { - expect( - choosePostgresPassword(undefined, 'sim-abc', { shell: { POSTGRES_PASSWORD: 'Ab9._~-z' } }) - ).toEqual({ value: 'Ab9._~-z', source: 'environment' }) - }) - it('reads the process environment by default', () => { vi.stubEnv('POSTGRES_PASSWORD', 'from-process') - expect(choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true })).toEqual( - { value: 'from-process', source: 'environment' } - ) + expect( + choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true }) + ).toBeNull() }) }) diff --git a/packages/sim-setup/src/compose-database.ts b/packages/sim-setup/src/compose-database.ts index da42b92a1bf..8af13f0686a 100644 --- a/packages/sim-setup/src/compose-database.ts +++ b/packages/sim-setup/src/compose-database.ts @@ -25,20 +25,13 @@ export function composeFileRequiresPostgresPassword(composeFile: string): boolea } /** Where a chosen `POSTGRES_PASSWORD` came from. */ -export type PostgresPasswordSource = 'environment' | 'generated' | 'legacy' +export type PostgresPasswordSource = 'generated' | 'legacy' export interface PostgresPasswordChoice { value: string source: PostgresPasswordSource } -/** - * A password the wizard can persist verbatim: Compose interpolates it unescaped - * into `DATABASE_URL`, and `.env` reinterprets whitespace, comments, quotes and - * `$`, so only URL-unreserved characters survive both unchanged. - */ -const PERSISTABLE_PASSWORD = /^[A-Za-z0-9._~-]+$/ - interface ChooseOptions { /** The shell environment, whose `POSTGRES_PASSWORD` Compose interpolates over `.env`. */ shell?: NodeJS.ProcessEnv @@ -47,18 +40,18 @@ interface ChooseOptions { /** * Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null - * when `.env` already has the right one. The production Compose file requires - * the variable, and the value must match what the data volume was created with — + * when nothing should be written. The production Compose file requires the + * variable, and the value must match what the data volume was created with — * Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a * wrong value locks the app out of its own database: * - * - A value exported in the shell is what Compose is using. It is persisted when - * `.env` has none, so a later run without the export does not fall back to a - * guess. An empty export, one that differs from `.env`, or one `.env` cannot - * hold verbatim is refused: which value the volume was created with cannot be - * known, and either silent choice can lock the app out. - * - With no value anywhere, an existing volume was created with the legacy - * password, and a project with no volume yet gets a generated one. + * - A value exported in the shell is the operator's to manage: Compose + * interpolates it over `.env`, so nothing is written. An empty export is + * refused, because Compose would use the empty value and refuse to start + * however good the one in `.env` is. + * - Otherwise a value in `.env` stands, an existing volume was created with the + * legacy password the file used to default to, and a project with no volume + * yet gets a generated one. */ export function choosePostgresPassword( envFileValue: string | undefined, @@ -66,38 +59,16 @@ export function choosePostgresPassword( { shell = process.env, hasDatabaseVolume = composeDatabaseVolumeExists }: ChooseOptions = {} ): PostgresPasswordChoice | null { const shellValue = shell.POSTGRES_PASSWORD - if (shellValue === undefined) { - if (envFileValue) return null - return hasDatabaseVolume(project) - ? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' } - : { value: generateSecret(), source: 'generated' } - } if (shellValue === '') { throw new SetupError( 'POSTGRES_PASSWORD is exported but empty, and Compose uses it over .env.', ['unset it (unset POSTGRES_PASSWORD) so the value in .env applies'] ) } - if (envFileValue) { - if (envFileValue === shellValue) return null - throw new SetupError( - 'POSTGRES_PASSWORD in the shell differs from the one in .env, so it is unclear which one the database was created with.', - [ - 'unset the exported POSTGRES_PASSWORD if .env holds the database password', - 'or set the exported value in .env if that is the database password', - ] - ) - } - if (!PERSISTABLE_PASSWORD.test(shellValue)) { - throw new SetupError( - 'POSTGRES_PASSWORD is exported only in the shell, and contains characters that cannot be stored in .env and embedded in DATABASE_URL unchanged.', - [ - 'use only letters, digits and . _ ~ - (for a new install: openssl rand -hex 24)', - 'or add it to .env yourself if you have confirmed it works in a connection URL', - ] - ) - } - return { value: shellValue, source: 'environment' } + if (shellValue !== undefined || envFileValue) return null + return hasDatabaseVolume(project) + ? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' } + : { value: generateSecret(), source: 'generated' } } /** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */ @@ -159,10 +130,6 @@ export function reportPostgresPasswordChoice( choice: PostgresPasswordChoice, { compose, user, envPath }: { compose: string; user: string; envPath: string } ): void { - if (choice.source === 'environment') { - p.log.step(`Saved POSTGRES_PASSWORD from the shell environment to ${envPath}`) - return - } if (choice.source === 'generated') { p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`) return From 6b45b97abc30411a7fb653e60f1834a567bdbc33 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 15:52:14 -0700 Subject: [PATCH 6/6] docs(self-hosting): note that POSTGRES_* values must be URL-safe docker-compose.prod.yml composes DATABASE_URL from them as written, so a value containing a URL delimiter initializes the database but breaks the connection string. --- apps/docs/content/docs/platform/self-hosting/docker.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docs/content/docs/platform/self-hosting/docker.mdx b/apps/docs/content/docs/platform/self-hosting/docker.mdx index df179c71bdf..56cfe3a5027 100644 --- a/apps/docs/content/docs/platform/self-hosting/docker.mdx +++ b/apps/docs/content/docs/platform/self-hosting/docker.mdx @@ -43,6 +43,8 @@ EOF Do not set `DATABASE_URL` or `BETTER_AUTH_URL` in `.env` — `docker-compose.prod.yml` composes both on the service definition, and a value set here is ignored. Change `POSTGRES_*` and `NEXT_PUBLIC_APP_URL` instead. + + Because `DATABASE_URL` is composed from them, keep `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` URL-safe — letters, digits, and `.` `_` `~` `-`. They are inserted into the connection string as written, so a value containing `@`, `/`, `?`, `#`, `%`, or a space can initialize the database while leaving the app and migrations unable to connect. `openssl rand -hex` output is always safe.