[@layout]: ../docs.layout.md

# Server deployment

`@pathmx/deploy` updates writable PathMX sites over SSH. It adopts an existing
macOS user LaunchAgent running checkout-local `pmx serve`.

The authoring and server
checkouts must share a canonical network Git repository. Discovery requires SSH
access and Python 3; activation requires a compatible project-local deployment
package and Bun on the server.

---

## Configure a target

First complete [manual server bootstrap](../start/publish.guide.md#supervise-a-live-process)
and verify its running LaunchAgent and health. Return to the authoring checkout
to install/configure the deployment target:

```sh
bun run pmx plugins add deploy
bun run pmx deploy init --host user@server --directory /absolute/checkout
```

In a terminal, omit host and directory to enter them when prompted. Setup verifies
repository identity, selects the checkout's service, and saves local configuration.
It does not install packages or change configuration on the server. Bootstrap the
server with compatible project dependencies before its first deployment. Commit
and push local configuration before deploying. Success prints “Saved production
in package.json” and the dry-run command. If discovery finds no eligible service,
return to the bootstrap and health checks instead of assuming init created one.

```json
{
  "pathmx": {
    "plugins": {
      "@pathmx/deploy": {
        "targets": {
          "production": {
            "adapter": "server",
            "host": "user@server",
            "directory": "/absolute/checkout",
            "service": "net.example.site",
            "branch": "main",
            "liveSources": ["paths/responses"],
            "backupPaths": ["uploads"]
          }
        }
      }
    }
  }
}
```

Use `--name staging` to create another target; existing targets are not overwritten.
`--service` resolves ambiguous discovery. `--branch` selects the authoring branch,
defaulting to the local current branch. `--live-source` declares directories where
the site saves work; only those paths may change in production, and checkpoints
commit them. `--backup-path` adds durable files or directories outside those
Sources and the conventional runtime state.

---

## Service prerequisites

The service must be enabled and running in the user's launchd GUI domain, using
its original plist and direct checkout-local `pmx serve`. Configure host and port
through the service environment or repository `.env`. Linked worktrees, symlinked
durable paths, and hidden Git index changes are refused. Ignore `.pathmx/` and
`.env` in Git.

The server checkout stays on the authoring branch, usually `main`. It needs Git
2.38 or later, a commit identity (`git config user.name` and `user.email`), and
push access to the shared repository, because the site lands saved work there.
The server's remote for that repository must have exactly one push URL, and it
must be the shared repository; deployment refuses a different or additional
`pushurl`.

When HTTPS terminates at a reverse proxy, set `PATHMX_ORIGIN` to the exact public
origin, for example `https://course.example`. The upstream connection may use
`http://127.0.0.1:3000`; browser Actions are checked against the configured public
origin, including its scheme and port. Forwarded host/protocol headers alone do
not configure that origin. Preserve the browser's `Origin` header at the proxy.

When one reverse proxy connects to PathMX, set `PATHMX_TRUSTED_PROXY` to that
proxy's exact IP address. The server then accepts one valid IP in
`X-Forwarded-For` from that peer for credential rate limiting. It ignores forwarding
headers from every other peer and rejects ambiguous multi-hop values. Without this
setting, the direct socket peer is the client address. If a trusted proxy omits or
malforms the header, requests share the limiter's unknown-client bucket rather than
mistaking the proxy socket for a unique visitor.

---

<!-- id: saved-work -->

## Saved work reaches main

Deployment installs a checkpoint LaunchAgent named after the service, for example
`net.example.site.checkpoint`. It runs when loaded and then every minute:

1. It copies each SQLite database in the service's database directory to
   `.pathmx/deploy/backups/latest/` and keeps an hourly copy for 30 days under
   `.pathmx/deploy/backups/hourly/`. Copies are consistent snapshots checked with
   `PRAGMA quick_check`, taken while the site keeps running.
2. It commits changed files under `liveSources` as “Checkpoint saved work”.
3. It lands that commit on the authoring branch of the shared repository. When
   authors have pushed since the last deployment, it pushes a merge commit built
   without changing the running checkout, so the site keeps serving its deployed
   revision until the next `pmx deploy`.

Authors receive saved work with `git pull`. While learners are saving, the shared
branch usually moves every minute, so pull before pushing. Anyone who can read the
shared repository can read the saved work in it; keep course repositories private.

If an author and the site changed the same file, the checkpoint keeps its commit
on the server and stops landing. `pmx deploy --dry-run` lists the files as a
blocker. Make those files on the authoring branch match the server checkout, for
example from `git -C /absolute/checkout show HEAD:<path>` on the server, then push;
the next checkpoint lands. Each run records its outcome in
`.pathmx/deploy/checkpoint/status.json`.

The checkpoint and deployment share the directory lock `.git/pathmx-checkpoint.lock`
and record their owner in it. A checkpoint that finds the lock held skips that
minute. It reclaims a lock left by a checkpoint process that no longer exists, but
never removes a deployment's lock or a lock without an owner record. Those are
recorded in `status.json`, and the dry run reports one held for more than 10
minutes as a blocker. If a commit fails, for example because the commit identity
is missing, the saved work stays staged and the next checkpoint commits it.

---

## Coordinate other jobs

`checkpointJobs` declares other LaunchAgents that touch the checkout, such as a
standby synchronization job. Deployment's own checkpoint needs no entry. Each
listed job must acquire `.git/pathmx-checkpoint.lock` before writing and release
it afterward. Configuring its label alone does not make an arbitrary job
cooperate. Verify that contract before adding it with `--checkpoint-job <label>` or
editing the manifest.

Deployment acquires the lock, pauses job scheduling, and waits for active jobs to
finish before stopping the site. It retains the lock through restoring their
original loaded/enabled state. Unconfigured related jobs prevent activation. Direct
filesystem editors must also remain idle during maintenance.

---

## Check and deploy

```sh
bun run pmx deploy --dry-run
bun run pmx deploy
bun run pmx deploy staging
```

One target is selected automatically. With several, supply a name or configure
`default` beside `targets`. Preflight checks repository identity, local cleanliness,
selected commit availability, that the server checkout is on the authoring branch,
its Git version, unlanded saved-work conflicts, service discovery, related jobs,
and runner protocol compatibility. It changes no remote application, state,
services, or infrastructure. If remote branch ancestry cannot be established from
local objects, fetch that branch locally and retry.

The dry-run report starts with `Deployment preflight: production` and identifies
the selected revision, server branch, last checkpoint, service, and any `Blocked:`
entries. It also
states that service health and backups have not yet been verified. Resolve every
blocker before running activation.

---

## Activate a verified target

Deployment fetches the exact requested commit and verifies readiness. Before
pausing jobs or stopping the service, it merges that revision with a copy of current
live Sources in a disposable checkout, installs locked dependencies there, and
lints the combined candidate. Conflicts, installation failures and validation
errors at this stage leave the running site and learner files unchanged.
The check uses the project's `.env` with a fresh temporary database directory;
it does not copy or migrate active production databases.

The runner then coordinates writers, stops the service, checkpoints live Sources,
verifies a backup, merges, installs locked dependencies, and lints again before
restarting and checking readiness. The report records the live revision, merged
tree and time checked by preliminary validation. Learners can save while that
check runs, so final validation against the drained checkpoint is still required;
a preliminary pass is not a guarantee that activation will succeed.

The backup includes `db/`, `.env`, live Source directories (including ignored
files inside them), and `backupPaths`. The supervisor resolves `PATHMX_DB_DIR`
from its service environment before project `.env`; relative values are based on
the project root. Configured external SQLite files and their WAL/SHM companions
are copied into the backup's `database/` directory, with the original location and
verified hashes recorded in `manifest.json`. Keep that directory private and
coordinate its writers before deployment. Symlinks and changing backup contents
are refused. Backups and original supervisor configuration stay private under
`.pathmx/deploy/operations/<operation>/`.

After readiness, deployment writes the checkpoint configuration, installs or
updates the checkpoint job, and resumes jobs. The checkpoint runs at once and lands
the deployed revision's saved work.

Success starts with `Deployment complete: production` and records the deployed
revision, backup, and service/job state. Compare the revision with your intended
commit and open the public site to check representative content and signed-in
behavior. Keep the saved report with deployment evidence.

The installed runner completes the operation even when its own package is replaced.
No server-wide daemon is required.

---

## Resolve a stopped deployment

The CLI saves a report under `.pathmx/deploy/reports/` and prints its location.
The server also records timestamped operation state. Reports identify revisions,
conflicts, backup locations, verified service state, and exact inspection commands
without including credentials or Source contents.

A preliminary failure leaves the running service unchanged. If newer live edits
cause a merge conflict after shutdown, that merge is aborted and the previous
application is restarted and checked. Failures after installing dependencies into
the live checkout leave maintenance active; Git rollback alone
cannot restore runtime state. The operation retains deployment/checkpoint locks
when recovery is uncertain. Inspect the saved supervisor configuration and backups
with the maintainer before restoring service/jobs or removing locks. Never remove
an active runner's locks or reset away server history.

A lost SSH connection means the outcome is unknown, even if the remote runner
continues. Inspect the operation report and live service before retrying. These
distinctions make the report usable as a self-contained handoff to a privileged
maintainer or agent.

---

<!-- id: move-off-live-branch -->

## Move a server off a separate live branch

Servers set up with an earlier `@pathmx/deploy` keep production on a separate
branch, often `live`. Move one once, while no deployment is running.
Upgrade the authoring checkout to the selected release first. A protocol-2 CLI
cannot activate through a protocol-1 runner. Bootstrap only the self-contained
runner from the installed authoring package; leave the running application's
manifest, lockfile and other dependencies in place until activation:

```sh
# In the authoring checkout, after installing the selected release:
scp node_modules/@pathmx/deploy/runner.mjs user@server:/absolute/checkout/node_modules/@pathmx/deploy/runner.next.mjs

# On the server, using its configured Bun executable:
cd /absolute/checkout
bun --no-env-file node_modules/@pathmx/deploy/runner.next.mjs --protocol
# Continue only when this prints {"protocol":2}.
cp node_modules/@pathmx/deploy/runner.mjs node_modules/@pathmx/deploy/runner.previous.mjs
mv node_modules/@pathmx/deploy/runner.next.mjs node_modules/@pathmx/deploy/runner.mjs
```

The runner is one bundled file and needs no imports from the old application.
An interrupted upload leaves the installed runner unchanged. Keep the previous
runner until the upgrade succeeds. Do not replace a runner while a deployment or
checkpoint is in progress.

Then migrate the checkout:

1. On the server, stop and remove any custom checkpoint LaunchAgent. Remove its
   label from `checkpointJobs`, and delete `replicate` from the target.
2. In the authoring checkout, merge the server's branch, for example
   `git fetch user@server:/absolute/checkout live` and `git merge FETCH_HEAD`.
   Resolve conflicts in saved-work files with the server's version
   (`git checkout --theirs -- <path>`), then push.
3. On the server, run `git switch -C main` in the checkout. Files and
   uncommitted saved work stay as they are.
4. Review `liveSources` against every place the site can save content, including
   custom Plugin grants and public pages editable through Studio. Add those exact
   files/directories to the target. Undeclared saved edits block deployment and
   remain preserved; Deploy does not infer writable paths from arbitrary Plugins.
5. Run `bun run pmx deploy --dry-run`, then `bun run pmx deploy`. Deployment
   installs the checkpoint job, which lands saved work from then on.
