Skip to content

Commit c813bdb

Browse files
committed
fix(server): separate migration ownership from startup
1 parent 88c0f06 commit c813bdb

5 files changed

Lines changed: 103 additions & 5 deletions

File tree

apps/docs/content/docs/self-hosting/docker.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Notes:
7373

7474
### Prebuilt image: use Postgres (full flavor)
7575

76-
The `relay-server` image is built from the same server code and can run full flavor too — you just override the defaults. Postgres is the external database supported by the prebuilt image. On startup, the image applies its bundled Postgres migrations before starting the server; set `RUN_MIGRATIONS=0` only when migrations are managed separately.
76+
The `relay-server` image is built from the same server code and can run full flavor too — you just override the defaults. Postgres is the external database supported by the prebuilt image. On startup, the image applies its bundled Postgres migrations before starting the server. Health-managed or multi-replica deployments should instead use the dedicated migration step described under [Migrations](#migrations).
7777

7878
Example `docker-compose.yml` (Postgres + local files backend):
7979

@@ -555,10 +555,23 @@ services:
555555

556556
The source-built `server` target and prebuilt `relay-server` image run DB migrations by default. Migration selection follows `HAPPIER_DB_PROVIDER`, independently of the preset: Postgres, MySQL, and PGlite use their packaged/provider migration owner; SQLite uses the server's canonical in-process startup migration path.
557557

558+
The default is convenient for a single container. For Postgres, MySQL, or PGlite deployments managed by an orchestrator, run migrations once before replacing application containers:
559+
560+
```bash
561+
docker compose run --rm relay run-server --migrate-only
562+
docker compose up -d --force-recreate relay
563+
```
564+
565+
Set `RUN_MIGRATIONS=0` on the long-running API and worker services. The explicit `--migrate-only` command still runs migrations when that environment value is present, exits only after migration completion, and never starts the server. Your deployment system must treat a non-zero exit as a failed deployment and leave the existing application containers running.
566+
567+
If your platform cannot run and await a blocking pre-deploy command (for example, separate Dokploy Applications triggered by independent webhooks), designate exactly one API service as the migration owner instead: set `RUN_MIGRATIONS=1` only there, set `RUN_MIGRATIONS=0` on every worker and other replica, use a start-first rollout with automatic rollback, and give the migration-owning container enough health-check startup grace for the largest expected migration. This fallback relies on backward-compatible migrations so workers may continue serving while the API owner upgrades the schema.
568+
558569
Notes:
559570

560571
- Disable automatic migrations with `RUN_MIGRATIONS=0`. For SQLite, the entrypoint maps this to the server startup migration setting; it does not launch a second migration process.
561-
- In Postgres multi-replica setups, it’s OK if more than one replica tries to migrate at startup (the DB serializes via locks).
572+
- Keep SQLite on its normal single-process startup path; the packaged SQLite runtime does not support `--migrate-only`.
573+
- PostgreSQL advisory locks serialize competing migration attempts, but they do not protect a migration if an orchestrator terminates its container for failing startup health checks. Use one dedicated migration owner when migrations can outlive the platform's startup grace period.
574+
- If migration deployment is interrupted, inspect the database and migration ledger before recovery. Never mark an unknown failed migration as applied merely to make the server start.
562575

563576
## Reverse proxy checklist
564577

apps/server/scripts/run-server.sh

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
#!/bin/sh
22
set -eu
33

4+
migrate_only=0
5+
if [ "${1:-}" = "--migrate-only" ]; then
6+
migrate_only=1
7+
shift
8+
fi
9+
410
server_binary="${1:-}"
511
if [ "$#" -gt 1 ]; then
6-
echo "[entrypoint] Usage: run-server.sh [packaged-server-binary]"
12+
echo "[entrypoint] Usage: run-server.sh [--migrate-only] [packaged-server-binary]"
713
exit 1
814
fi
915
if [ -n "$server_binary" ] && [ ! -x "$server_binary" ]; then
@@ -22,6 +28,9 @@ migrations_enabled=1
2228
if is_false "${RUN_MIGRATIONS:-1}" || is_false "${HAPPIER_STACK_PRISMA_MIGRATE:-1}"; then
2329
migrations_enabled=0
2430
fi
31+
if [ "$migrate_only" = "1" ]; then
32+
migrations_enabled=1
33+
fi
2534

2635
provider="$(printf "%s" "${HAPPIER_DB_PROVIDER:-${HAPPY_DB_PROVIDER:-postgres}}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
2736
flavor="$(printf "%s" "${HAPPIER_SERVER_FLAVOR:-${HAPPY_SERVER_FLAVOR:-full}}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
@@ -45,6 +54,11 @@ case "$provider" in
4554
;;
4655
esac
4756

57+
if [ "$migrate_only" = "1" ] && [ "$should_migrate" = "0" ]; then
58+
echo "[entrypoint] --migrate-only is not supported by the packaged SQLite runtime."
59+
exit 1
60+
fi
61+
4862
export HAPPIER_DB_PROVIDER="$provider"
4963
export HAPPY_DB_PROVIDER="$provider"
5064
export HAPPIER_SERVER_FLAVOR="$flavor"
@@ -128,6 +142,11 @@ if [ "$should_migrate" = "1" ] && [ "$migrations_enabled" = "1" ]; then
128142
fi
129143
fi
130144

145+
if [ "$migrate_only" = "1" ]; then
146+
echo "[entrypoint] Migrations complete."
147+
exit 0
148+
fi
149+
131150
if [ -n "$server_binary" ]; then
132151
exec "$server_binary"
133152
fi

apps/server/scripts/run-server.sh.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,67 @@ describe('run-server.sh', () => {
185185
]);
186186
});
187187

188+
it('runs only migrations when --migrate-only is requested', async () => {
189+
const serverPath = await writeFakePackagedRuntime({ dir: tmpDir, logPath });
190+
const res = spawnSync('sh', [getScriptPath(), '--migrate-only', serverPath], {
191+
env: {
192+
...process.env,
193+
HAPPIER_SERVER_FLAVOR: 'full',
194+
HAPPIER_DB_PROVIDER: 'postgres',
195+
DATABASE_URL: 'postgresql://postgres@db/happier',
196+
RUN_MIGRATIONS: '0',
197+
MIGRATIONS_MAX_ATTEMPTS: '1',
198+
MIGRATIONS_RETRY_DELAY_SECONDS: '0',
199+
},
200+
stdio: 'pipe',
201+
encoding: 'utf8',
202+
});
203+
204+
expect(res.status).toBe(0);
205+
expect(await readLogLines(logPath)).toEqual([
206+
'MIGRATE provider=postgres url=postgresql://postgres@db/happier',
207+
]);
208+
});
209+
210+
it('runs only the source-backed migration command when --migrate-only is requested', async () => {
211+
const res = spawnSync('sh', [getScriptPath(), '--migrate-only'], {
212+
env: {
213+
...process.env,
214+
PATH: `${binDir}:${process.env.PATH ?? ''}`,
215+
HAPPIER_SERVER_FLAVOR: 'full',
216+
HAPPIER_DB_PROVIDER: 'postgres',
217+
DATABASE_URL: 'postgresql://postgres@db/happier',
218+
RUN_MIGRATIONS: '0',
219+
MIGRATIONS_MAX_ATTEMPTS: '1',
220+
MIGRATIONS_RETRY_DELAY_SECONDS: '0',
221+
},
222+
stdio: 'pipe',
223+
encoding: 'utf8',
224+
});
225+
226+
expect(res.status).toBe(0);
227+
const yarnLines = (await readLogLines(logPath)).filter((line) => line.startsWith('YARN '));
228+
expect(yarnLines).toEqual(['YARN --cwd apps/server migrate:deploy']);
229+
});
230+
231+
it('fails closed when --migrate-only cannot own packaged SQLite migrations', async () => {
232+
const serverPath = await writeFakePackagedRuntime({ dir: tmpDir, logPath });
233+
const res = spawnSync('sh', [getScriptPath(), '--migrate-only', serverPath], {
234+
env: {
235+
...process.env,
236+
HAPPIER_SERVER_FLAVOR: 'light',
237+
HAPPIER_DB_PROVIDER: 'sqlite',
238+
RUN_MIGRATIONS: '0',
239+
},
240+
stdio: 'pipe',
241+
encoding: 'utf8',
242+
});
243+
244+
expect(res.status).toBe(1);
245+
expect(res.stdout).toContain('--migrate-only is not supported by the packaged SQLite runtime');
246+
expect(await readLogLines(logPath)).toEqual([]);
247+
});
248+
188249
it('delegates packaged SQLite migration to normal server startup', async () => {
189250
const serverPath = await writeFakePackagedRuntime({ dir: tmpDir, logPath });
190251
const res = spawnSync('sh', [getScriptPath(), serverPath], {

docs/deployment.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ Key notes:
9090
- The server defaults to port `3005` (set `PORT` explicitly in container environments).
9191
- The image includes FFmpeg and Python for media processing.
9292
- The server entrypoint (`apps/server/scripts/run-server.sh`) runs `prisma migrate deploy` on startup by default (set `RUN_MIGRATIONS=0` to disable). On Postgres, it retries on advisory-lock contention.
93+
- Health-managed and multi-replica deployments should run `run-server --migrate-only` as a single pre-deploy operation, then start API and worker replicas with `RUN_MIGRATIONS=0`. The migration command exits before server startup and explicitly overrides `RUN_MIGRATIONS=0`.
94+
- PostgreSQL advisory locks serialize concurrent migrators but cannot preserve a migration when the owning container is terminated. The pre-deploy operation's lifetime and timeout must be independent from application startup health checks.
95+
- If the platform cannot block application rollout on a pre-deploy operation, use exactly one API migration owner (`RUN_MIGRATIONS=1`), disable migrations on all workers and other replicas, and configure start-first rollout, rollback on failure, and a health startup grace long enough for the largest expected migration. Separate auto-deploy webhooks are not an ordering mechanism.
9396

9497
## Kubernetes manifests
9598
Example manifests live in `apps/server/deploy`:

docs/release-process.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ The reset option exists for rare cases where you intentionally want `target` to
256256

257257
For the server, database migrations should be automated as part of the deployment runtime:
258258

259-
- Run `prisma migrate deploy` at container startup (entrypoint) or via an explicit platform “pre-deploy” hook.
260-
- Running migrations from *both* API and worker is acceptable as long as you expect contention and handle it (Prisma uses a DB lock to serialize migrations; the non-holder should wait/retry).
259+
- For a single unmanaged container, the default entrypoint may run `prisma migrate deploy` before server startup.
260+
- For health-managed or multi-replica deployments, run `run-server --migrate-only` once in an explicit platform pre-deploy operation. Start API and worker replicas with `RUN_MIGRATIONS=0` only after that operation succeeds.
261+
- When an application platform cannot run and await a blocking pre-deploy operation, designate exactly one API service as the migration owner and set `RUN_MIGRATIONS=0` on workers and all other replicas. Protect that owner with start-first rollout, rollback on failure, and sufficient health-check startup grace; webhook acceptance alone does not prove migration or deployment completion.
262+
- Do not rely on API and worker startup races as migration ownership. Prisma's database lock serializes contenders, but it cannot preserve the winning migration when an orchestrator terminates that container for missing its startup-health window.
261263
- Avoid running migrations at image build-time (Dockerfile), since migrations require a live DB connection.

0 commit comments

Comments
 (0)