# Deploying an update (/docs/operations/deploying)



```sh
./bin/deploy.sh              # deploy
./bin/deploy.sh --dry-run    # show what would be sent and removed, change nothing
```

<Callout type="warn">
  **Use the script rather than the steps.** This procedure has been reconstructed from memory more
  than once, and each reconstruction dropped a different step. None of the omissions failed loudly;
  they were found later, by someone wondering why the box was in a state nobody had chosen.
</Callout>

Overridable with `DEPLOY_HOST` and `DEPLOY_ROOT`, which is also how you would deploy a second one.

## The steps that look optional and are not [#the-steps-that-look-optional-and-are-not]

**`rm -rf node_modules` before installing.** The deploy ends with `pnpm prune --prod`. A later
`pnpm install --frozen-lockfile` then reports "Already up to date" and does **not** restore
devDependencies, so the next build fails on a missing Vite.

**Removing `node_modules` also removes pnpm's shims**, so use `corepack pnpm` rather than `pnpm`
from that point on, or the very next line is `pnpm: command not found`.

**`rsync --delete`, over the whole tree.** Syncing a list of paths leaves behind files deleted or
renamed in the repository. A stale module that still resolves is an hour of debugging.

**`rsync --no-owner --no-group`.** Plain `-a` carries the *developer machine's* uid across, so the
controller's code ends up owned by a number that means nothing there. The `chown` at the end used
to be what corrected this, which meant every deploy fought itself and a failure between the two
steps left the wrong answer in place. Not sending ownership removes the race; the `chown` stays as
the thing that states the intended result rather than repairs an unintended one.

**`chown -R root:pve-agents`.** The service user must be able to read its code and must not
own it.

**Migrate before starting, not after.** The controller applies migrations lazily, on the first
request that touches the database. Skip this and it reports itself healthy on the old schema and
surfaces the failure as a broken request rather than a failed deploy.

**Stop the service before syncing.** Otherwise the tree is replaced under a running process.

## Open the site afterwards [#open-the-site-afterwards]

`check`, `typecheck`, `test` and `build` do not load a page. All four have passed against a build
whose client bundle threw during hydration and rendered nothing but an error box. The script cannot
catch this; a person has to look. Anything touching `src/router.tsx`, routing or SSR especially.

## Dependencies are pinned exactly [#dependencies-are-pinned-exactly]

Because the deploy deletes `node_modules` every time, a floating version specifier means a deploy
can install something other than what last worked, with nothing in the diff to show for it.

`@tanstack/react-router` and `@tanstack/react-start` were once `"latest"`. A fresh install
eventually paired them with `@tanstack/react-router-with-query`, which had stopped at 1.130 while
the router went on to 1.170 and still declared a peer range of `">=1.43.2"`. It called an API the
router no longer had, hydration threw, and the whole UI was an error box. &#x2A;*Treat a wide peer range
as no guarantee at all.**
