logicsrc/docs/openinstall.md
Anthony Ettinger e62c546a1e
Some checks are pending
CI / build (push) Waiting to run
Deploy to dev2 / deploy (push) Waiting to run
test / test (push) Waiting to run
OpenInstall 0.1: one idempotent bin/install.sh that puts an app into service (#221)
* OpenInstall 0.1: one idempotent bin/install.sh that puts an app into service

A repository carries bin/install.sh and, when the defaults are not right,
bin/install.conf. Run on a box from a checkout it installs the runtime and a
local Postgres or Redis, builds, writes a systemd unit and an nginx site with
TLS, restarts and health-checks the app. Phases setup, build, activate and
status; exit 0, 1 or 3; never overwrites a file without its marker line;
secrets only in STATE_DIR/app.env. sh1pt's deploy-ssh install.sh is the
reference script and `sh1pt ship --target deploy-ssh` the reference deployer.

Registered in the process family, with a landing page at /openinstall and a
contract test holding the page's tables to the spec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(openinstall): describe DATABASE_URL without a credentials-shaped URL

ThreatCrush flagged the placeholder postgres URL in the worked example.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 03:08:14 -07:00

19 KiB

OpenInstall

OpenInstall is one script every application repository carries at bin/install.sh. Run on a server from a checkout, it puts the application into service on that box: the runtime and the local services it needs, its dependencies and its build, a systemd unit, an nginx site with a certificate, a restart and a health check. It is idempotent, so running it a second time changes nothing, and it never touches a file on the box it did not write. A person runs it by hand after git clone; a deployer runs the same script, phase by phase, inside a release directory. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface.

Status: 0.1. A description of a script the Profullstack fleet already ships, published so any repository can carry one and any deployer can run it.

Slug: openinstall

The problem

Every application that runs on a plain server needs the same dozen steps: install a runtime, create a database, install dependencies, build, write a service unit, write a reverse-proxy site, get a certificate, restart, check that it answers. Most repositories keep those steps in a README section that was true once, in a deploy script on one person's laptop, or in a platform's dashboard that is gone the day the bill stops being paid. A new box means doing them again from memory, and a second run of a half-written script means a duplicate database user or an nginx site with two server blocks.

The pieces exist. systemd runs services, nginx terminates TLS, certbot issues certificates, apt installs the rest, and every one of them can be driven from a shell script without a person at the keyboard. What is missing is an agreed place for that script, an agreed way to call it, and a contract strong enough that a deployer can run any repository's copy without reading it first: the same phases, the same settings file, the same exit codes, the same promise not to break what is already on the box.

Terms

  • A box is one server the application runs on: bare metal, a VPS, a virtual machine. One operating system, one systemd, one nginx.
  • A checkout is a copy of the repository's tree on the box, from git clone, git archive or rsync.
  • The script is bin/install.sh in a repository.
  • A phase is one of the script's verbs: setup, build, activate, status.
  • A setting is one KEY=value the script reads, from bin/install.conf or the environment.
  • A secret is a value that must not be committed: an API key, a database password, a signing key.
  • A deployer is anything that places a checkout on a box and runs the script for it: a person with ssh, sh1pt ship --target deploy-ssh, a CI job.
  • A release is one checkout a deployer built, kept in its own directory so the previous one is still there.

The script

bin/install.sh, executable, at the root of the repository. The reference implementation is a single bash file with no dependencies beyond what a Debian or Ubuntu box has: sh1pt packages/targets/deploy-ssh/bin/install.sh. A repository copies it into bin/, commits it, and writes a bin/install.conf beside it when the defaults are not right.

./bin/install.sh            # setup + build + activate
./bin/install.sh setup      # runtime (bun/node), postgres, redis
./bin/install.sh build      # dependencies + build, in the checkout
./bin/install.sh activate   # systemd unit, nginx + TLS, restart, health check
./bin/install.sh status     # key=value lines
./bin/install.sh help       # the header, with every setting

Phases

The first argument is the phase. No argument means all.

phase what it does needs root
setup Creates STATE_DIR (mode 0700) and an empty app.env (0600) when there is none. Installs the runtime when it is missing: bun from bun.sh for RUNTIME=bun; for RUNTIME=node it requires Node to be present. Provisions a local Postgres database and role when POSTGRES is set and a local Redis when REDIS=1, recording DATABASE_URL and REDIS_URL in db.env. for apt, Postgres and Redis
build Loads db.env and app.env, then runs INSTALL_CMD and BUILD_CMD in SRC_DIR, the build with NODE_ENV=production. Writes nothing outside the checkout and the package manager's cache. no
activate Writes STATE_DIR/run.sh and the systemd unit, reloads systemd if either changed, enables and restarts the unit, and waits for the health check. Only after the application answers does it write the nginx site for DOMAINS, test it with nginx -t, reload, and request a certificate when there is none. For RUNTIME=static there is no unit: nginx serves STATIC_DIR from APP_DIR. for systemd and nginx
status Prints key=value lines (app, runtime, src, app_dir, state, unit, active, health, domains) and exits 0. Changes nothing. no
all setup, then build, then activate, stopping at the first failure. as above

Root means root, or a user with passwordless sudo. The script never prompts; a step that needs root and cannot get it exits 1 and says which step. The service itself runs as the user who ran the script, not as root.

Settings

bin/install.conf holds the settings, one KEY=value per line, # comments, values optionally quoted. It is committed and it never holds a secret. The environment wins over the file, so a deployer or a person can override any key for one run without editing it.

key meaning default
APP Name, used for the unit and the nginx site. Lower-cased, anything outside a-z0-9- becomes -. the repository directory's name
RUNTIME auto, bun, node or static. auto reads the lockfiles: bun.lock or bun.lockb is bun, a pnpm, npm or yarn lockfile is node, a bare package.json is bun, no package.json is static. auto
INSTALL_CMD auto, none or a command. auto is the frozen-lockfile install for the lockfile found. auto
BUILD_CMD none or a command. <pm> run build when package.json has a build script, else none
START_CMD The command the service runs. Required unless RUNTIME=static. <pm> run start when package.json has a start script
PORT The application listens here, on loopback. Passed to the service as PORT. 3000
HEALTH_PATH activate GETs this until it answers 2xx or 3xx. /
HEALTH_TIMEOUT Seconds to wait for that answer. 120
DOMAINS Space- or comma-separated host names. An nginx site for them; none when empty. empty
TLS 1 requests a Let's Encrypt certificate with certbot, 0 serves plain http. 1
TLS_EMAIL The ACME account email. none
STATIC_DIR RUNTIME=static: the directory nginx serves, relative to APP_DIR. dist
SPA 1: an unknown path falls back to /index.html. 0
POSTGRES 0, 1 or a database name. 1 names the database and its role after APP, with - as _. 0
REDIS 0 or 1. 0
MAX_BODY nginx client_max_body_size. 100m
SRC_DIR The checkout to build. the repository the script is in
APP_DIR Where the service runs from. SRC_DIR
STATE_DIR Where app.env, db.env and run.sh live. ~/.local/share/<APP>
UNIT The systemd unit name, without .service. APP

A key the script does not know is still read and exported, so a build command can see it.

State

Three files in STATE_DIR, which outlives every checkout and every release:

file written by holds
app.env the deployer, or a person The secrets, as KEY=value lines, mode 0600. Created empty by setup when missing and never written by the script after that. A deployer writes it from a vault; nobody commits it.
db.env setup DATABASE_URL and REDIS_URL for what setup provisioned, mode 0600. A line that is already there is never rewritten, so a database password is generated once.
run.sh activate The service's entry point: cd APP_DIR and exec START_CMD.

The service reads db.env and then app.env, and build sources them in the same order, so a value in app.env wins. An application that uses an external database sets DATABASE_URL in app.env and leaves POSTGRES=0.

The files the script writes outside STATE_DIR are /etc/systemd/system/<UNIT>.service, the nginx site at /etc/nginx/sites-available/<APP>.conf with its sites-enabled link (or /etc/nginx/conf.d/<APP>.conf where there is no sites-available), and the certificate certbot keeps under /etc/letsencrypt/live/<first domain>/. Each file it writes starts with the marker line # managed by bin/install.sh.

Exit codes

code meaning
0 The phase did what it says. For status, always.
1 An error: a bad setting, a missing tool, a failed build, a step that needed root, a file that is not ours, an nginx configuration that did not test. The script stops at the step that failed and says which.
3 activate restarted the service and the health check did not pass within HEALTH_TIMEOUT. The last 40 lines of the unit's journal are printed. The nginx site was not touched.

A deployer treats any non-zero code from activate as a failed release and puts the previous one back.

The rules

A script conforms to OpenInstall 0.1 when it does all of these.

1. It lives at bin/install.sh

At the root of the repository, executable, with a shebang. It runs without a terminal and never prompts. Settings live beside it in bin/install.conf when there are any. That path is the whole of discovery: a deployer looks for the file in the checkout, and finds it or uses its own generic copy.

2. The first argument is a phase

setup, build, activate, status, or nothing for all, which is setup, build and activate in that order. help prints the settings. Any other argument exits 1 and names the phases.

3. Every step checks before it acts

Installing a package checks for the command first. Creating a database checks for the role and the database. A file is written only when its content differs from what is there, and systemd and nginx are reloaded only when a file they read changed. A second run with nothing new changes no file on the box. The one thing activate always does is restart the service, because that is how a fresh build takes effect.

4. It never overwrites a file it did not write

Every unit, site and entry point the script writes carries the marker # managed by bin/install.sh. Before writing one, the script looks at the file already there; if it exists without the marker, the script exits 1 and says to move it aside or pick another APP or UNIT. An application installed on a box that already runs other things cannot take their names.

5. Settings come from bin/install.conf and the environment

KEY=value lines, committed, no secrets. The environment wins. Every key has a default, so a repository with a package.json, a lockfile and build and start scripts needs no install.conf at all to be built and run on port 3000.

6. Secrets live in STATE_DIR/app.env, and only there

Mode 0600, in a directory of mode 0700, written by whoever holds the secrets. Never in the repository, never in install.conf, never in the unit file, never on a command line. The script creates it empty when it is missing and otherwise only reads it.

7. Build, run and state are three directories

SRC_DIR is built, APP_DIR is run, STATE_DIR is kept. By default the first two are the same checkout. A deployer that builds each release in its own directory sets SRC_DIR=releases/<id>, APP_DIR=current (a symlink it flips) and a STATE_DIR that every release shares. The unit points at APP_DIR, so flipping the symlink and running activate switches the release, and flipping it back rolls one back.

8. activate proves the service is up before it routes to it

After the restart the script requests http://127.0.0.1:PORT plus HEALTH_PATH until it answers 2xx or 3xx or HEALTH_TIMEOUT passes. Only then does it touch nginx. On timeout it exits 3.

9. A proxy change that does not test is put back

The new nginx site is tested with nginx -t before the reload. When the test fails the previous file is restored, or the new one removed when there was none, and the script exits 1. A certificate that cannot be issued yet, usually because DNS does not point at the box, is a warning: the site serves plain http and the next run tries again.

10. Exit codes are 0, 1 and 3

As in the table above. A deployer reads nothing else to decide whether a release went out.

11. status changes nothing

It prints key=value lines, one fact per line, and exits 0 whether or not the service is up. active is what systemd says; health is the HTTP status of the health check, or down when nothing answers.

12. It puts one application on the box it runs on

The script does not reach other machines, does not create the user it runs as, does not configure the firewall, ssh or the kernel, and does not touch another application's unit, site or database. Two applications on one box are two checkouts, two APP names and two runs.

Deployers

A person is a deployer:

git clone https://git.example.com/acme/ledger.git
cd ledger
./bin/install.sh

sh1pt ship --target deploy-ssh is the reference deployer. It takes the source from any git host the box can read (GitHub, GitLab, Codeberg, or a self-hosted forge such as git.chovy.com) or pushes it with rsync, and lays the box out as:

<root>/repo.git                  a mirror of the repository
<root>/releases/<id>             one directory per release, <UTC yyyymmddHHMMSS>-<sha7>
<root>/current -> releases/<id>  what the unit runs
<root>/shared/                   STATE_DIR: app.env, db.env, run.sh, deploys.log

For each release it:

  1. Places the checkout in releases/<id>.
  2. Writes shared/app.env from the vault, atomically, mode 0600.
  3. Runs the repository's bin/install.sh setup and build with SRC_DIR=releases/<id>, APP_DIR=current and STATE_DIR=shared. When the repository has no bin/install.sh, it runs a bundled copy of the reference script. A build failure stops here, and the release that was current stays current.
  4. Flips current to the new release and runs activate.
  5. On a non-zero exit, flips current back and runs activate for the previous release.
  6. Records the outcome and prunes old releases.

Rollback is the same flip to an older release followed by activate. Any other deployer that follows these steps can run any conforming script.

Worked example

A bun application called ledger, with a Postgres database and a domain. The repository has bun.lock and a package.json with build and start scripts, and the application answers GET /healthz. Its bin/install.conf:

# bin/install.conf: committed, no secrets
APP=ledger
PORT=3000
HEALTH_PATH=/healthz
DOMAINS="ledger.example.com www.ledger.example.com"
TLS_EMAIL=ops@example.com
POSTGRES=1

On a fresh Ubuntu box, as a user deploy with passwordless sudo, ./bin/install.sh does this:

  1. setup: finds bun.lock, so RUNTIME=bun. Installs bun for deploy. Installs postgresql, creates the role and database ledger with a random password, and appends a DATABASE_URL for that role and database on 127.0.0.1:5432, password included, to ~/.local/share/ledger/db.env.
  2. build: runs bun install --frozen-lockfile, then bun run build with NODE_ENV=production, with DATABASE_URL and everything in app.env in its environment.
  3. activate: writes ~/.local/share/ledger/run.sh and /etc/systemd/system/ledger.service, enables and restarts ledger, and waits for http://127.0.0.1:3000/healthz. Then installs nginx, writes /etc/nginx/sites-available/ledger.conf as a plain http proxy for both names, tests and reloads it, runs certbot for both names, rewrites the site with the certificate and an http to https redirect, and reloads again.

The secrets go in ~/.local/share/ledger/app.env before the first run or between runs, by hand or from a deployer:

SESSION_SECRET='...'
COINPAY_API_KEY='...'

Run it again and the only thing that happens is a restart and a health check: bun is there, the database is there, DATABASE_URL is already in db.env, the unit and the site have the same content, and the certificate exists. ./bin/install.sh status then prints:

app=ledger
runtime=bun
src=/home/deploy/ledger
app_dir=/home/deploy/ledger
state=/home/deploy/.local/share/ledger
unit=ledger
active=active
health=200
domains=ledger.example.com www.ledger.example.com

Not

Not a provisioner of the box. It does not create users, harden ssh, open ports, set up swap or install a kernel. The box is given; the script puts one application on it.

Not container orchestration. No images, no scheduler, no cluster. One box, systemd and nginx. A team that wants Kubernetes has it; this is the other path.

Not a package manager. It does not resolve dependencies or publish anything. It calls the package manager the lockfile names, and apt for the few system packages it needs.

Not a CI system. It does not run tests or decide whether a commit should ship. It builds and runs what it is given.

Not a secret store. It reads app.env; it does not know where the values came from and never writes them.

Not a release manager. The script knows one checkout. Releases, the current symlink, rollback and pruning belong to the deployer.

No .well-known file. The script is read by whoever has the checkout. Nothing about it is served from a site.

Relationship to other standards

  • OpenStack.md says what a project is built on; its Hosting section can say bin/install.sh (OpenInstall): systemd and nginx on one box. The runtime the script detects is the one OpenStack.md lists.
  • OpenServer is what a hosting provider sells. OpenInstall is what runs on the box once it is bought.
  • OpenFleet is the record an agent session carries; an agent deploying through sh1pt runs this script under that record.
  • GitHub's "Scripts to Rule Them All" (script/bootstrap, script/setup, script/server) is the closest earlier idea: a fixed place in every repository for the commands that set it up. That pattern is for a developer's laptop; this one is for the server, with the idempotency and ownership rules a server needs.
  • A Procfile names the start command for a platform that does the rest. Here the rest is in the repository.
  • A Dockerfile builds an image for a container runtime. A repository may carry both; OpenInstall is the path with no container runtime on the box.

Version history

Version Date Change
0.1 2026-10-01 First publication: bin/install.sh and bin/install.conf, four phases and all, the settings and their defaults, STATE_DIR with app.env, db.env and run.sh, the ownership marker, exit codes 0, 1 and 3, the build, run and state split, what a deployer owes the script, with sh1pt's script as the reference implementation and sh1pt ship --target deploy-ssh as the reference deployer.

License

The specification text is CC BY 4.0. Serve it, copy it, extend it.