███████╗██████╗ ██╗███╗ ██╗██╗ ██╗██████╗
██╔════╝██╔══██╗██║████╗ ██║██║ ██║██╔══██╗
███████╗██████╔╝██║██╔██╗ ██║██║ ██║██████╔╝
╚════██║██╔═══╝ ██║██║╚██╗██║██║ ██║██╔═══╝
███████║██║ ██║██║ ╚████║╚██████╔╝██║
╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝
Your dev environment, one word away
spinup is a command-line tool that starts a project's local development environment
with one command. Run it once inside a repository: it detects the stack, writes a
.spinup.yml describing each service, and installs a command named after the project.
From then on, typing that name from any directory starts every service in dependency
order, waiting for ports, HTTP endpoints or log lines, in a tmux workspace or in the
foreground. One binary for Linux and macOS, MIT licensed.
Documentation: quick start · installation · configuration · commands · detection · tmux sessions · upgrading
Renamed from
runit. The old name collides with the UNIX service supervisor of the same name on apt, Homebrew and npm. Existing installs migrate themselves on the next run: the registry moves to~/.config/spinup, generated commands are rewritten, and a project's existing.runit.ymlkeeps being used as-is.
| Platform | Build | Tested in CI |
|---|---|---|
| Linux x64 | glibc and musl (Alpine) | test suite and binary smoke test |
| Linux arm64 | glibc and musl (Alpine) | test suite and binary smoke test |
| macOS arm64 (Apple Silicon) | native | test suite and binary smoke test |
| macOS x64 (Intel) | native | test suite and binary smoke test |
Every release runs its smoke test on each published file, on the machine it is for. On Windows, use WSL.
- tmux 3.0 or newer, only for actions using
mode: tmux.spinuppasses each pane its command, working directory, and environment through tmux itself, which needs the-eflag added in 3.0. Actions usingmode: simpleneed no tmux. - Docker, only for projects with a Compose file.
spinup <alias> --check reports what a given project actually needs.
An alias becomes a real command in ~/.local/bin, so the accepted format is narrow:
- lowercase letters, digits,
-and_ - must start with a letter or digit, at most 64 characters
Uppercase input is lowercased, so MyApp and myapp are the same project. . and
: are rejected because tmux reads them as session/window/pane separators.
spinup refuses an alias that would shadow a command already on your PATH, and
refuses to overwrite a file in the shim directory that it did not create. Aliases
registered before these rules existed are renamed automatically on the next run,
and the change is reported.
Registering a project without a config scans it and writes .spinup.yml. Nothing
is executed during the scan except docker compose config, which starts nothing.
The command for each service is chosen in this order:
- A command the project defines for development: an executable
bin/dev, the entries ofProcfile.dev(exceptrelease), or adevrecipe, target or task injustfile,Makefile,Taskfile.ymlormise.toml. It runs on its own, because it already starts what it needs. Other launchers found are reported. - A root
devscript in a monorepo, which is its orchestrator (turbo, nx, …). - Each workspace member:
dev,start:dev,develop,serve, thenstart.testandcheckare never chosen. Declaredworkspacesandpnpm-workspace.yamlglobs are expanded, including!exclusions; without a declaration,apps/*,services/*andpackages/*are considered. - Python apps whose entrypoint is found in source:
manage.py, or anapp = FastAPI()/app = Flask()object, run throughuv run,poetry runor a local.venvwhen the project uses one. - Compose as a single
docker compose up, using Compose's own file precedence, override file and profiles. Profiled services stay optional.
The package manager comes from packageManager in package.json, then the
lockfile, and a disagreement between them is reported. Services with the same name
are told apart by runtime (app-node, app-python) or path (apps-api,
services-api).
Each generated service carries a comment saying where its command came from, and
--doctor shows the same for a fresh scan. When nothing runnable is found, spinup
asks for the command in a terminal and fails elsewhere; it never writes a guess.
Latest release:
curl -fsSL https://raw.githubusercontent.com/PedroElizalde01/spinup/main/install.sh | bashThe installer picks the build for your OS, architecture and C library, verifies it
against the release's SHA256SUMS, confirms it runs, and only then replaces an
existing binary. Update later with spinup --update. See
installation for verifying provenance, shell completion and
uninstalling.
Then run:
spinup --helpInstall a specific version:
curl -fsSL https://raw.githubusercontent.com/PedroElizalde01/spinup/main/install.sh | \
bash -s -- --version v0.5.0If spinup is not found after install, add this to your shell profile:
export PATH="$HOME/.local/bin:$PATH"Register the current project and create the alias command:
spinup my-appOr step by step, reviewing every detected command before anything is written:
spinup --initThen run it with the generated shim:
my-appIf the alias already exists, running spinup my-app will only tell you that it is already registered.
Running the alias again while its tmux session is still up attaches to that session instead of rebuilding it. A session with the same name that spinup did not create, or that belongs to another project or action, is never touched: the launch fails and tells you how to attach to or end it.
Inspect and manage a registered project:
spinup my-app --doctor
spinup my-app --check
spinup my-app --plan
spinup my-app --graph
spinup my-app --env
spinup my-app --edit
spinup my-app --edit --interactive
spinup my-app --removeEvery generated config can hold several actions (dev, docker, prisma-migrate,
…). --action selects one for inspection or launch; the default action is used
otherwise:
my-app --action prisma-migrate # launch a non-default action
spinup my-app --plan --action docker # inspect exactly what that launch would do
my-app --dry-run # resolve everything, start nothingManage a running tmux workspace. These act only on the session spinup created for this project and action, and refuse any other session with the same name:
my-app --status # running? each service's state; exits 3 when not running
my-app --attach
my-app --restart api # respawn one service and wait until it is ready
my-app --restart # the whole session
my-app --stopInside a project directory the alias can be left out. spinup --start runs the
directory's .spinup.yml as it is, without registering anything or installing a
command, and spinup --status, --stop, --plan and the rest act on that project.
The generated command forwards its flags, so my-app --doctor inspects instead of
launching. Inspection commands accept --json and then print one JSON document on
stdout with nothing else; environment values are never included.
Regenerate the config from the current project structure:
spinup my-app --regenerateList registered projects:
spinup --listspinup <alias>: register if needed, otherwise report that the alias already existsspinup <alias> --regenerate: rescan the repo and replace.spinup.ymlafter a preview and confirmation; the previous file is kept as.spinup.yml.bakspinup <alias> --doctor: inspect config, actions, stack detection, and tool availabilityspinup <alias> --check: validate required tools and config paths for the selected actionspinup <alias> --plan: print start order, resolved directories, dependencies and delaysspinup <alias> --graph: a tree of what waits for what, with readiness conditionsspinup <alias> --env: show loaded environment keys and their origin, values maskedspinup <alias> --edit: open the config in$EDITORspinup <alias> --edit --interactive: edit the default action with prompts, keeping commentsspinup <alias> --remove: remove the registered project and generated shimspinup --list: list registered projects with their default action and config statespinup <alias> --status: whether the tmux session is running and each service's statespinup <alias> --attach: attach to the running tmux sessionspinup <alias> --stop: end the tmux sessionspinup <alias> --restart [service]: restart one service, or the whole sessionspinup --version: print the installed versionspinup --init: register step by step, reviewing detected commands and previewing what is writtenspinup <alias> --path <dir>: register a directory other than the current onespinup <alias> --relink: point an alias at the current directory, or--path, after a move or for a worktreespinup --update [version]: replace the binary with a verified releasespinup --completion bash|zsh|fish: print a shell completion script
Modifiers: --action <name> (launch, plan, graph, env, check, doctor, status,
attach, stop, restart), --logs (launch, restart a whole session: write each
service's output to a private log), -y/--yes (answer confirmations, for scripts),
--json (list, plan, graph, env, check, doctor, status), --dry-run (launch), --no-color or the
NO_COLOR environment variable.
| Status | Meaning |
|---|---|
| 0 | Success |
| 1 | Usage, configuration or registration error |
| 2 | --check or --doctor found the selected action cannot run |
| 3 | --status: the session is not running |
| n | A task in a simple action exited with status n; spinup returns it unchanged |
| 130 | Stopped by Ctrl+C after the tasks were shut down |
| 143 | Stopped by SIGTERM after the tasks were shut down |
In simple mode every task runs in its own process group. On failure or on a
signal, spinup sends SIGTERM to each group, waits three seconds, and kills whatever
is still running, so a shell's children do not outlive the run.
Examples below use a sample npm workspace with apps/api, apps/web and a compose.yaml running Postgres and Redis.
$ spinup my-app
┌────────────────────────────────────────────────────────────┐
│ █▀▀ █▀█ █ █▄ █ █ █ █▀█ │
│ ▄▄█ █▀▀ █ █ ▀█ █▄█ █▀▀ │
│ │
│ alias my-app │
│ status registered │
│ command ~/.local/bin/my-app │
│ root ~/code/my-app │
├────────────────────────────────────────────────────────────┤
│ stack mixed │
│ package npm │
│ frameworks Docker Compose, Express, React, Vite │
│ services compose, api, web │
├────────────────────────────────────────────────────────────┤
│ actions dev (default), docker │
│ action dev │
│ mode tmux │
│ windows 1 (services) │
│ panes 3 (compose, api, web) │
│ layout tiled │
├────────────────────────────────────────────────────────────┤
│ next my-app │
└────────────────────────────────────────────────────────────┘
$ spinup my-app
┌────────────────────────────────────────────────────────────┐
│ █▀▀ █▀█ █ █▄ █ █ █ █▀█ │
│ ▄▄█ █▀▀ █ █ ▀█ █▄█ █▀▀ │
│ │
│ alias my-app │
│ status already registered │
│ command ~/.local/bin/my-app │
│ root ~/code/my-app │
├────────────────────────────────────────────────────────────┤
│ stack mixed │
│ package npm │
│ frameworks Docker Compose, Express, React, Vite │
│ services compose, api, web │
├────────────────────────────────────────────────────────────┤
│ actions dev (default), docker │
│ action dev │
│ mode tmux │
│ windows 1 (services) │
│ panes 3 (compose, api, web) │
│ layout tiled │
├────────────────────────────────────────────────────────────┤
│ next my-app │
└────────────────────────────────────────────────────────────┘
$ spinup my-app --doctor
+------------------+
| Project Doctor |
+------------------+
Project: my-app
Path: /home/user/code/my-app
Config file:
/home/user/code/my-app/.spinup.yml ✓
Actions:
dev (default)
docker
Inspecting: dev (tmux)
Stack detection:
mixed ✓
frameworks Docker Compose, Express, React, Vite
A fresh scan would generate:
compose: docker compose up (compose.yaml)
api: npm run dev (apps/api/package.json scripts.dev)
web: npm run dev (apps/web/package.json scripts.dev)
Services in this action:
compose
api
web
Package manager:
npm
Tmux: required
installed ✓
Docker: required
installed ✓
Status:
ready
$ spinup my-app --check
+--------------------+
| Environment Check |
+--------------------+
tmux ✓
node ✓
docker ✓
npm ✓
$ spinup my-app --plan
+------------------+
| Execution Plan |
+------------------+
Action: dev (tmux)
Root: /home/user/code/my-app
Start order:
1. compose ready when port localhost:5432
cwd /home/user/code/my-app
docker compose up
2. api after compose
cwd /home/user/code/my-app/apps/api
npm run dev
3. web after compose, api
cwd /home/user/code/my-app/apps/web
npm run dev
Windows:
services (tiled): compose, api, web
$ spinup my-app --graph
+-----------------+
| Service Graph |
+-----------------+
Action: dev
compose ready when port localhost:5432
└─ api
└─ web also after compose
$ spinup my-app --env
+---------------+
| Environment |
+---------------+
Action: dev
Files: .env, .env.local
Loaded environment variables:
API_URL=*** [.env]
DATABASE_URL=*** [.env.local]
SESSION_SECRET=*** [.env] (overridden by the shell)
$ spinup my-app --edit
# opens $EDITOR with .spinup.yml
# no terminal output on success
$ spinup my-app --edit --interactive
? Edit action "dev"
❯ Add service
Remove service
Change command
Change cwd
Toggle mode (current: tmux)
Save changes
Cancel
↑↓ navigate • ⏎ select
✔ Edit action "dev" Cancel
Edit cancelled.
$ spinup my-app --regenerate
[scan] scanning project
[detect] stack: mixed
[detect] package manager: npm
[detect] frameworks:
- Docker Compose
- Express
- React
- Vite
[detect] services:
- compose: docker compose up (compose.yaml)
- api: npm run dev (apps/api/package.json scripts.dev)
- web: npm run dev (apps/web/package.json scripts.dev)
[config] proposed changes:
~ dev.api.cmd: npm start -> npm run dev
[config] regenerating replaces .spinup.yml entirely.
[config] custom actions, comments and formatting not listed above are lost.
? Replace the config? (y/N) y
[config] updated .spinup.yml (previous copy in .spinup.yml.bak)
$ spinup my-app --remove
Removed project "my-app" (/home/user/code/my-app)
$ spinup --list
Registered projects:
my-app dev (tmux) ~/code/my-app
$ spinup my-app --status
my-app (dev) is running in tmux session "my-app".
compose running pid 48211
api running pid 48230
web exited status 1
$ spinup my-app --restart web
Restarted web.
$ spinup my-app --stop
Stopped my-app (dev).
$ spinup --help
Usage: spinup [options] [alias]
Run registered project environments from anywhere.
Arguments:
alias registered project alias
Options:
-v, --version output the version number
-a, --action <name> act on this action instead of the default
--check validate required tools for a registered project
--doctor inspect a registered project
--env show loaded environment variables
--edit edit the project config
--graph show what waits for what, as a tree
--interactive use interactive prompts with --edit
--plan preview the execution plan
--dry-run with --start: resolve everything and start nothing
--json print inspection results as JSON
--no-color disable colored output
-r, --regenerate re-scan the project and overwrite the project config
--remove remove a registered project and its shim
--list list registered projects
--status show whether the tmux session is running and each service's state
--attach attach to the running tmux session
--stop end the tmux session
--restart [service] restart one service in the tmux session, or the whole session
-h, --help display help for command
--start is omitted from the help because the generated command passes it. It means
"launch unless a management flag was given", so my-app --plan previews and my-app
launches. spinup --start <alias> never registers anything.
Like foreman, overmind, honcho or mprocs, spinup runs several processes at once. Unlike them, it finds the commands itself, orders services by dependencies and readiness, registers a global command per project, and manages the tmux session it creates: status, restart one service, attach, stop, logs.
It does not replace Docker Compose or a task runner such as turbo or make. It starts them as services, alongside the processes that run outside containers.
bun install
bun run check
bun run build
./dist/spinup --helpBump version in package.json, move the CHANGELOG's unreleased notes under the new
version, then:
git tag -a v0.5.0 -m "v0.5.0"
git push origin main v0.5.0The release workflow refuses a tag that disagrees with package.json, runs the full
CI, builds every target, runs the smoke test on each file on its own platform, and
publishes the binaries, spinup.1, SHA256SUMS and build provenance. With a
HOMEBREW_TAP_TOKEN secret it also updates the PedroElizalde01/homebrew-spinup tap.
Generated files are checked by tests; regenerate them after changing their source:
bun run scripts/build-schema.ts # schema/spinup.schema.json from the config schema
bun run scripts/site-hero-card.ts # site/hero-card.json from the setup card code| Path | Purpose |
|---|---|
.spinup.yml |
Per-project config, committed with the repo (.runit.yml is still read if present) |
${XDG_CONFIG_HOME:-~/.config}/spinup/projects.json |
Alias-to-path registry |
~/.local/bin/<alias> |
Generated command for each registered project |
XDG_CONFIG_HOME relocates the registry; a relative value is ignored, as the XDG
spec requires. SPINUP_SHIM_DIR relocates generated commands, which is mainly useful
for testing.
Environment files are read from the action's root (root in the config) in this
order, with later files winning:
.env -> .env.local -> .env.<action> -> .env.<action>.local
<action> is the action's own name, so a dev action reads .env.dev. A file for
a different action, such as .env.production while running dev, is reported as
skipped rather than silently ignored.
Precedence, highest first:
- A task's own
env:block in the config - A value already set in the invoking shell, e.g.
PORT=4000 my-app - The environment files above
Values are passed to each service directly and never echoed into a terminal. Use
spinup <alias> --env to see which keys are loaded, which file each came from, and
which are being overridden by your shell. Values stay masked.
Generated projects use a .spinup.yml file like this:
version: 1
name: my-app
root: .
default: dev
actions:
dev:
mode: simple
tasks:
- name: app
cwd: .
cmd: npm run devValidation is strict: an unknown key, a blank command, an environment variable name
the shell cannot export, a dependency cycle or a simple action with no tasks is
rejected with the path of the offending field. version is optional and means 1
when absent; a file with a newer version than this build understands is refused
with an upgrade message.
Every cwd resolves against root, which resolves against the project directory.
Every service starts as soon as the services it dependsOn are ready. A service
with no ready condition counts as ready once it has started, plus its delay. A
condition makes its dependents wait for something real:
tasks:
- name: db
cwd: .
cmd: docker compose up postgres
ready: { port: 5432 } # accepts TCP connections (host defaults to localhost)
- name: migrate
cwd: .
cmd: npx prisma migrate deploy
dependsOn: [db]
ready: { exit: 0 } # a one-shot step that must succeed
- name: api
cwd: apps/api
cmd: npm run dev
dependsOn: [migrate]
ready: { http: "http://localhost:3000/health", timeout: 60000 } # any answer below 500
- name: web
cwd: apps/web
cmd: npm run dev
dependsOn: [api]
ready: { log: "ready in \\d+ ms" } # a line of output matches this patterntimeout is in milliseconds and defaults to two minutes. A condition that fails,
times out, or whose process exits first stops the run and names the condition. In
tmux the session is kept so the failing pane can be inspected. Services that do not
depend on each other are never held up by one another. A task's env: block wins over the invoking shell, which wins over the
selected environment files.