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 and verify its running LaunchAgent and health. Return to the authoring checkout to install/configure the deployment target:

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.

{
  "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.

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

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.

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:

# 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.