Skip to content

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 as registry.example.com: the registry the official images are published at, or your own, for images you build yourself with deploy/prod/build-images.sh.
  1. 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 sqlite3
    snap install lxd --channel=5.21/stable
    lxd init --auto
  2. Check that LXD runs from the snap. The compose file mounts /var/snap/lxd/common/lxd into the controller.

    Terminal window
    ls -d /var/snap/lxd/common/lxd
  3. Check that Caddy runs and that the caddy group exists. The host Caddy reaches the web through its group.

    Terminal window
    systemctl is-active caddy
    getent group caddy

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.

Terminal window
useradd --system --user-group --home-dir /opt/ProjectStart --no-create-home --shell /usr/sbin/nologin project
id -u project
id -g project

Don’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.

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:

Terminal window
install -o root -g root -m 0755 <checkout>/deploy/prod/install-folders.sh /usr/local/sbin/projectstart-install-folders
PS_UID="$(id -u project)" PS_GID="$(id -g project)" /usr/local/sbin/projectstart-install-folders

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

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.

  1. 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 of domains.
    • 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 no systemctl, so without it every route change fails.
  2. Set its owner and mode:

    Terminal window
    chown root:root /opt/ProjectStart/etc/policy.json
    chmod 0644 /opt/ProjectStart/etc/policy.json

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

Terminal window
printf '{ "user": "signin" }\n' > /opt/ProjectStart/etc/agents.json
chown root:root /opt/ProjectStart/etc/agents.json
chmod 0644 /opt/ProjectStart/etc/agents.json

A sign-in through the controller’s container is not among the facts recorded from the clean-install test.

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.

Terminal window
install -o root -g root -m 0755 <checkout>/deploy/prod/caddy-helper.sh /usr/local/sbin/projectstart-caddy-helper
install -o root -g root -m 0644 <checkout>/deploy/prod/projectstart-caddy-helper.path /etc/systemd/system/projectstart-caddy-helper.path
install -o root -g root -m 0644 <checkout>/deploy/prod/projectstart-caddy-helper.service /etc/systemd/system/projectstart-caddy-helper.service
systemctl daemon-reload
systemctl enable --now projectstart-caddy-helper.path

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

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.

Terminal window
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy || systemctl start caddy
  1. 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
  2. Write /opt/ProjectStart/deploy/.env, owned by root with mode 0600, as it names the images. Every variable is required; docker compose refuses to start without one.

    Terminal window
    PS_WEB_IMAGE=<registry>/projectstart:latest
    PS_WORKER_IMAGE=<registry>/projectstart-worker:latest
    PS_CONTROLLER_IMAGE=<registry>/projectstart-controller:latest
    PS_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_DOMAINS is optional: other domains no project may take, comma-separated, such as an earlier installation’s site.

    • Name each image by its :latest tag, so a deploy is only docker compose pull and up -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, as timedatectl show -p Timezone --value prints it. Agent schedules use it. Where timedatectl can’t reach systemd (it failed so in a container on dev), /etc/localtime names the zone: readlink /etc/localtime prints /usr/share/zoneinfo/<zone>.
    Terminal window
    chown root:root /opt/ProjectStart/deploy/.env
    chmod 0600 /opt/ProjectStart/deploy/.env
  3. Sentry, optional. Web and worker get the variables in /opt/ProjectStart/etc/sentry.env when 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_DSN is optional too; it turns on error reporting from the browser (app/src/hooks.client.ts).

    Terminal window
    SENTRY_DSN=<dsn>
    SENTRY_ENVIRONMENT=production
    Terminal window
    chown root:root /opt/ProjectStart/etc/sentry.env
    chmod 0600 /opt/ProjectStart/etc/sentry.env
  1. 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
  2. Make the sealing keys, once. The worker needs /opt/ProjectStart/var/worker/secret.key and /opt/ProjectStart/var/db/secret.pub to 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.pub

    Expect secret.key as project, mode 0600, and secret.pub as project, mode 0640.

Terminal window
docker compose -f /opt/ProjectStart/deploy/compose.yaml up -d --wait

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

While no administrator exists, the web prints a setup code in its log each time it starts. Only the latest code works.

  1. Read the code:

    Terminal window
    docker compose -f /opt/ProjectStart/deploy/compose.yaml logs web | grep setup.nonce

    The code is the setupCode field of that line.

  2. Open https://<PS_APP_DOMAIN>/setup. Enter an email, a password (12 to 200 characters) and the code.

  3. The setup page closes once an administrator exists.

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