Install
From an empty Ubuntu host to a running ProjectStart site.
Three containers run from deploy/prod/compose.yaml: controller (root, reaches LXD and Caddy’s files), worker and
web (both as the project account).
Steps 1 to 12 ran end to end on a clean ubuntu:26.04 container on 2026-10-03 (the clean-install test), and again on 2026-10-05 from pushed images pulled by digest, before the checks of Backup and restore, Upgrade and Way back. Notes below name what those runs did not cover.
Placeholders:
<checkout>: a checkout of the repository at the commit the images were built from.<registry>: where the images are (<registry>/projectstart,…/projectstart-worker,…/projectstart-controller), such asregistry.example.com: the registry the official images are published at, or your own, for images you build yourself withdeploy/prod/build-images.sh.
1. Prerequisites
Section titled “1. Prerequisites”-
Install Docker with Compose, Caddy and
sqlite3(for backups, Backup and restore) from Ubuntu’s packages, and LXD from the snap. These are what the clean-install test installed.Terminal window apt-get install -y docker.io docker-compose-v2 caddy sqlite3snap install lxd --channel=5.21/stablelxd init --auto -
Check that LXD runs from the snap. The compose file mounts
/var/snap/lxd/common/lxdinto the controller.Terminal window ls -d /var/snap/lxd/common/lxd -
Check that Caddy runs and that the
caddygroup exists. The host Caddy reaches the web through its group.Terminal window systemctl is-active caddygetent group caddy
2. The project account
Section titled “2. The project account”Web and worker run as the host account project, home /opt/ProjectStart. Make it as a system account and read its
ids. .env and install-folders.sh take them as PS_UID and PS_GID.
useradd --system --user-group --home-dir /opt/ProjectStart --no-create-home --shell /usr/sbin/nologin projectid -u projectid -g projectDon’t choose the ids by hand. On a fresh ubuntu:26.04, gid 983 belongs to polkitd and 993 to the sgx group.
Production keeps the ids its project account already has, 993:983.
3. Host folders
Section titled “3. Host folders”deploy/prod/install-folders.sh makes every host folder the compose file mounts, and /opt/ProjectStart/deploy,
which holds compose.yaml and .env, each with its owner, group and mode. It leaves a correct folder alone and stops,
with the reason, at a folder whose owner, group or mode differs. It never changes an existing folder. Run it as root:
install -o root -g root -m 0755 <checkout>/deploy/prod/install-folders.sh /usr/local/sbin/projectstart-install-foldersPS_UID="$(id -u project)" PS_GID="$(id -g project)" /usr/local/sbin/projectstart-install-foldersWithout PS_UID and PS_GID the script uses production’s ids, 993 and 983.
Its check of existing folders has run on dev: as root on a development host’s real layout, with PS_UID=1000 PS_GID=1000, it reported all 13 folders ok and exited 0. That was before /opt/ProjectStart/deploy was added; it
now checks 14. Its “created” path ran in the clean-install test.
What it makes, and who uses each folder:
| Folder | Owner:group | Mode | Used by |
|---|---|---|---|
/opt/ProjectStart |
root:root | 0755 | everything below |
/opt/ProjectStart/deploy |
root:root | 0755 | compose.yaml and .env, read by docker compose on the host |
/opt/ProjectStart/etc |
root:root | 0755 | host policy and settings, read-only in all three containers |
/opt/ProjectStart/ctl |
root:root | 0700 | controller: project backups, journals, locks, the Caddy helper’s requests |
/opt/ProjectStart/caddy |
root:root | 0755 | controller writes project route files, host Caddy reads them |
/opt/ProjectStart/login |
root:project | 0750 | sign-in sessions: the controller starts them as root, the worker follows them through its group and can’t change them |
/opt/ProjectStart/var |
root:root | 0755 | parent of the runtime folders; root’s, so the project account can’t replace a mounted subfolder with a link |
/opt/ProjectStart/var/db |
project:project | 0770 | web and worker: app.db, secret.pub, agent chats and files |
/opt/ProjectStart/var/worker |
project:project | 0700 | worker: secret.key |
/opt/ProjectStart/var/spool |
project:project | 0700 | worker writes inputs, controller reads them |
/opt/ProjectStart/var/work |
project:project | 0700 | worker’s Git work |
/opt/ProjectStart/var/run |
project:project | 0700 | worker’s socket worker.sock, which the web connects to |
/opt/ProjectStart/var/parts |
project:project | 0700 | web and worker: the remote runner’s parts for servers that reach neither nodejs.org nor npm (made when first needed; safe to empty) |
/opt/ProjectStart/var/ctl |
root:project | 0750 | controller’s socket ctl.sock (mode 0660, group project), which the worker connects to |
/opt/ProjectStart/web |
project:caddy | 2750 | web’s socket web.sock, which the host Caddy connects to |
/opt/ProjectStart/sites |
project:caddy | 2750 | worker’s sockets <container>.sock for the sites of containers on servers served through ProjectStart, which the host Caddy connects to; the controller reads it |
An installation made before /opt/ProjectStart/var became root’s has it as project:project 0750, which the script
refuses; give it the new owner and mode once, as root: chown root:root /opt/ProjectStart/var and chmod 0755 /opt/ProjectStart/var.
The controller refuses a spool folder owned by root (serviceUid in src/controller/projectctl.ts). The compose file
mounts every folder with create_host_path: false, so a missing folder stops docker compose up.
4. Host policy
Section titled “4. Host policy”The controller, the worker and the web read /opt/ProjectStart/etc/policy.json. It must be a regular file owned by
root, not writable by group or others, in a root-owned folder; web and worker must be able to read it.
-
Write the file with the production values in place of the
<…>fields:{"domains": ["<base domain of project sites>"],"gitHosts": ["<git host>"],"maxContainers": <maximum number of project containers>,"storagePool": "<LXD storage pool for project containers>","caddyHelper": true}domains,gitHosts: non-empty lists of host names. A project domain must be under one ofdomains.reservedDomains, optional: a non-empty list of host names the controller never takes as a project domain, such as the admin site’s (PS_APP_DOMAIN) and an earlier installation’s.maxContainers: an integer of 1 or more.isoHosts, optional: the host names a Windows ISO may be downloaded from (redirects included); absent, any https address.storagePool: required by the controller.caddyHelper: true: the controller asks the host’s Caddy helper to reload Caddy and read its routes (step 6). The controller image has nosystemctl, so without it every route change fails.
-
Set its owner and mode:
Terminal window chown root:root /opt/ProjectStart/etc/policy.jsonchmod 0644 /opt/ProjectStart/etc/policy.json
5. Sign-in account
Section titled “5. Sign-in account”Sign-ins to Codex and Claude from the web run as the account named in /opt/ProjectStart/etc/agents.json. The
controller image has the account signin for them. The controller refuses root and accounts it can’t find.
The images contain no Anthropic or OpenAI software. When the sign-in account has no Claude Code or Codex of its own
(the image’s signin has neither), the controller downloads them from registry.npmjs.org at its first start, in the
background, and again whenever the agent runner’s packages change: the runner’s own pinned packages
(deploy/agent-runner/package.json, checked against the integrity hashes in its lockfile), installed with npm ci as
the sign-in account without install scripts, then made root’s. Claude Code is the Claude Agent SDK’s binary, the same
version the agents run. So the controller’s container needs to reach registry.npmjs.org; a sign-in started before
the download finished waits for it, and one started after a failed download tries again. They are kept in
/opt/ProjectStart/cli inside the controller’s container (with the one for the previous runner version), so
recreating the container (an upgrade) downloads them again. A failed download is logged by the controller
(ctl.agent_clis).
printf '{ "user": "signin" }\n' > /opt/ProjectStart/etc/agents.jsonchown root:root /opt/ProjectStart/etc/agents.jsonchmod 0644 /opt/ProjectStart/etc/agents.jsonA sign-in through the controller’s container is not among the facts recorded from the clean-install test.
6. Caddy helper
Section titled “6. Caddy helper”The controller can’t reach the host’s systemd or Caddy’s admin API from its container. It leaves requests in
/opt/ProjectStart/ctl/caddy-helper, and a host helper answers them. deploy/prod/README-caddy-helper.md describes it.
install -o root -g root -m 0755 <checkout>/deploy/prod/caddy-helper.sh /usr/local/sbin/projectstart-caddy-helperinstall -o root -g root -m 0644 <checkout>/deploy/prod/projectstart-caddy-helper.path /etc/systemd/system/projectstart-caddy-helper.pathinstall -o root -g root -m 0644 <checkout>/deploy/prod/projectstart-caddy-helper.service /etc/systemd/system/projectstart-caddy-helper.servicesystemctl daemon-reloadsystemctl enable --now projectstart-caddy-helper.pathEnable only the path unit. It starts the service whenever a request is waiting. The host needs curl and systemctl.
Each answer leaves one line in journalctl -u projectstart-caddy-helper.
7. Caddyfile
Section titled “7. Caddyfile”Add the admin site and the project route import to /etc/caddy/Caddyfile as Reverse proxy
says, then reload Caddy. Caddy 2.6.2 can stop during a reload; start it again then.
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfilesystemctl reload caddy || systemctl start caddy8. Settings
Section titled “8. Settings”-
Copy the compose file into
/opt/ProjectStart/deploy(step 3 made the folder). Dev uses the same folder.Terminal window install -o root -g root -m 0644 <checkout>/deploy/prod/compose.yaml /opt/ProjectStart/deploy/compose.yaml -
Write
/opt/ProjectStart/deploy/.env, owned by root with mode 0600, as it names the images. Every variable is required;docker composerefuses to start without one.Terminal window PS_WEB_IMAGE=<registry>/projectstart:latestPS_WORKER_IMAGE=<registry>/projectstart-worker:latestPS_CONTROLLER_IMAGE=<registry>/projectstart-controller:latestPS_UID=<id -u project>PS_GID=<id -g project>PS_APP_DOMAIN=<the admin site's domain>TZ=<the host's time zone name>PS_RESERVED_DOMAINSis optional: other domains no project may take, comma-separated, such as an earlier installation’s site.- Name each image by its
:latesttag, so a deploy is onlydocker compose pullandup -d(Upgrade). Each release is also tagged with its date and time (YYYY.MM.DD-HHMM, Japan time), for going back (Way back). TZ: the zone name the host uses, astimedatectl show -p Timezone --valueprints it. Agent schedules use it. Wheretimedatectlcan’t reach systemd (it failed so in a container on dev),/etc/localtimenames the zone:readlink /etc/localtimeprints/usr/share/zoneinfo/<zone>.
Terminal window chown root:root /opt/ProjectStart/deploy/.envchmod 0600 /opt/ProjectStart/deploy/.env - Name each image by its
-
Sentry, optional. Web and worker get the variables in
/opt/ProjectStart/etc/sentry.envwhen the file exists; without it they report nothing. Compose reads the file on the host, so keep it root-only: it holds the DSN.Write the file with these lines.
PUBLIC_SENTRY_DSNis optional too; it turns on error reporting from the browser (app/src/hooks.client.ts).Terminal window SENTRY_DSN=<dsn>SENTRY_ENVIRONMENT=productionTerminal window chown root:root /opt/ProjectStart/etc/sentry.envchmod 0600 /opt/ProjectStart/etc/sentry.env
9. Images and keys
Section titled “9. Images and keys”-
Pull the images. When the registry needs a sign-in, sign in first with a read-only token; Docker asks for it, so it isn’t on the command line.
Terminal window docker login <registry host>docker compose -f /opt/ProjectStart/deploy/compose.yaml pull -
Make the sealing keys, once. The worker needs
/opt/ProjectStart/var/worker/secret.keyand/opt/ProjectStart/var/db/secret.pubto start, and no image makes them. This runs the worker image as the project account with the worker’s folders. It does nothing when either file exists.Terminal window docker compose -f /opt/ProjectStart/deploy/compose.yaml run --rm --no-deps worker node --no-warnings --input-type=module -e "const m = await import('/opt/ProjectStart/release/src/shared/secrets.ts'); m.SecretBox.ensureKeys('/opt/ProjectStart/var/db/secret.pub', '/opt/ProjectStart/var/worker/secret.key')"ls -l /opt/ProjectStart/var/worker/secret.key /opt/ProjectStart/var/db/secret.pubExpect
secret.keyas project, mode 0600, andsecret.pubas project, mode 0640.
10. Start
Section titled “10. Start”docker compose -f /opt/ProjectStart/deploy/compose.yaml up -d --waitThe compose file starts the controller first, then the worker once the controller is healthy, then the web once the
worker is healthy. Each health check connects to the service’s socket, so the web is healthy before the first
administrator exists. The worker creates app.db, or upgrades its schema, when it opens it
(Upgrade). Logs go to journald.
In the clean-install test, up -d --wait took 93 seconds; on 2026-10-05, 99 seconds.
11. First administrator
Section titled “11. First administrator”While no administrator exists, the web prints a setup code in its log each time it starts. Only the latest code works.
-
Read the code:
Terminal window docker compose -f /opt/ProjectStart/deploy/compose.yaml logs web | grep setup.nonceThe code is the
setupCodefield of that line. -
Open
https://<PS_APP_DOMAIN>/setup. Enter an email, a password (12 to 200 characters) and the code. -
The setup page closes once an administrator exists.
12. Check the site
Section titled “12. Check the site”These have run on dev through the containers: creating a project, moving it to the trash, restoring it, purging it, changing its domain, an agent turn and stopping it, and the Caddy helper, including a killed request and the check for its leftovers. Run the same on the new host. In the clean-install test, creating a project took 278 seconds and purging it 6 seconds. On 2026-10-05, creating one without a repository or agents took 493 seconds.
The browser registers the app’s service worker (app/src/service-worker.ts) only when it trusts the site’s
certificate. In the clean-install test it failed to register with Caddy’s local test certificate; everything else
worked with that certificate.
Project containers need network access to registry.npmjs.org. When an agent first starts in a project, and whenever
the agent runner’s files change, the controller runs npm ci for the runner inside the project container
(installRunner in src/controller/projectctl.ts; the lockfile is deploy/agent-runner/package-lock.json).
Projects on a remote host (a server the admin runs, reached by the worker over SSH) get the runner as one small file
of runner code (about 70 KB, the same for every platform): deploy/remote/build-runner.sh builds
projectstart-runner-<version>.tar.gz with VERSION, SHA256SUMS, PINS (the Node.js version and the packages the
runner code pins, with each Node.js archive’s SHA-256) and CHANGES.json (the runner’s changelog from Git history,
which the project page’s Update runner dialog shows), in deploy/remote/dist/ (build outputs, not committed). It
needs the root workspace’s esbuild (npm ci first). A build from a source without .git (a Docker build context)
needs PS_RUNNER_CHANGES naming the file deploy/remote/changes.sh <file> wrote in the repository first; without it
the build fails. The installed release carries them in /opt/ProjectStart/release/remote/ (cfg.remoteBundleDir),
where the worker takes them from to install or update a host’s runner, and the web serves a host given a reverse
connection its runner code. They must be built from the release’s own source: build-runner.sh --version prints the
version a source makes, which dist/VERSION must match. The worker and web images carry them, built from the images’
own source (the Dockerfile’s bundles stage; deploy/prod/build-images.sh writes the changelog file it needs, then
builds the images), so nothing is mounted. PS_REMOTE_BUNDLES names another folder for them, only to override that.
The development install (deploy/dev/install.sh) builds them again when they are not the source’s version, then
copies them there.
A server downloads Node.js from nodejs.org and Claude Code and Codex from registry.npmjs.org itself, checked
against the runner code’s pins. When it reaches neither, the worker (or, for a reverse connection, the web) sends them
instead from /opt/ProjectStart/var/parts (cfg.runnerPartsDir, PS_RUNNER_PARTS to override): Node.js’s archive,
downloaded from nodejs.org once and checked, and the packages, installed with npm ci for the server’s platform
once and packed (xz); so the installation itself must reach both sites for that fallback. The Codex CLI the runner
installs, and the one a project container installs for a Codex agent, is the version deploy/agent-runner/package.json
pins (dependencies["@openai/codex"], with the npm packages’ integrity hashes in its lockfile).