Lists the dev servers process-compose supervises on one host, and lets you open any of
them from a phone. List view and tile view, one switch between them. Light or dark, following
the system setting. Installable as a PWA.
You have a machine running several repos' dev servers and you want them on your phone. Portable Dev supervises them, and owns the way in: one Cloudflare Tunnel, one private route, one Access policy admitting one identity, and one firewall rule covering a port range. The index page is one tap per server, each showing live up/down state.
phone ──> Cloudflare Access ──> Tunnel ──> 10.99.99.1:18586 ──> Portable Dev
10.99.99.1:18587 ──> your dev server
10.99.99.1:18588 ──> another one
It does not proxy. Servers are addressed by port and answer for themselves; the tunnel is an L4 private route, so HMR and WebSockets pass through unchanged.
Everything inside the range shares one authorization boundary. Nothing outside it is covered at all — see What you are exposing.
cp .env.example .env # ports + Cloudflare credentials
cp process-compose.yaml.example process-compose.yaml
$EDITOR process-compose.yaml # register your repos
make setup # Cloudflare side; writes .tunnel-token
make up # everything: tunnel + index + serversmake setup is needed once, and again whenever PORT_RANGE changes — it is what makes the
range true on the Cloudflare side. make up runs bind-addr and firewall first: the lo
alias and the nftables rule are kernel state a reboot clears, so both are re-added every
time, and both need sudo. make install-net moves that to boot time instead — see
Keeping it across reboots.
make up builds, then hands the whole arrangement to process-compose: this index page
plus every process in the registry that is not marked disabled: true. make run starts
the index alone, unsupervised, when that is all you want. make attach reaches a detached
up over the same unix socket, and make down stops it.
BIND_ADDR defaults to 10.99.99.1, a private address held on lo. If nothing owns it
yet, make run fails with EADDRNOTAVAIL; ip addr add 10.99.99.1/32 dev lo creates it
for the current boot. Binding a real interface address works too — read the next section
first if you do.
Optional, and nothing else depends on it. make install-net installs one systemd unit that
re-adds the alias and the drop rule at boot, which is the only thing make up wanted sudo
for:
make install-net # one sudo now, none from `make up` afterwards
make uninstall-net # undo; `make up` goes back to re-adding both itselfIt writes /etc/systemd/system/portable-dev-net.service and /etc/portable-dev/firewall.nft.
Stopping the unit removes our nftables table and the alias, and never any other table.
Re-run make install-net after changing BIND_ADDR or PORT_RANGE.
Skipping it costs nothing but the sudo prompt: make up re-adds both itself, exactly as
it did before.
process-compose.yaml is the only source of truth. It is git-ignored — the repos on your
machine are yours, not the project's — so start from process-compose.yaml.example. A
process appears in the UI when its environment declares a PORT:
processes:
my-api:
description: My API # subtitle in the UI; optional
working_dir: ../my-api
command: aube run dev -- --host $BIND_ADDR --port $PORT
environment:
- PORT=8789 # this is what gets listed
- ICON=public/logo.svg # optional, relative to working_dirEach entry shows its repo's own favicon. With no ICON=, the conventional spots under
working_dir are tried — public/favicon.{svg,png,ico}, web/public/favicon.{svg,png},
static/favicon.ico, then the same names at the root — and a placeholder mark stands in
when nothing is found or the file later disappears. Icons are resolved once at startup and
served from /api/icon/<name>, so a request names a server, never a path.
Two rules, one enforced and one you have to keep:
- Every port must be inside
PORT_RANGE. Portable Dev refuses to start otherwise. The range is what the Access destination and the nftables rule actually cover — a port outside it is reachable by anyone who can route to the address and is authorized by nothing. That is a security bug, not a broken link. Widening it means editingPORT_RANGE, thenmake setup && make firewall—make setup && make install-netif you installed the unit. Leave it blank to switch the check off. - Bind
$BIND_ADDR.127.0.0.1is unreachable through a tunnel;0.0.0.0publishes the server to every network the host is on. Most dev servers need an explicit--host.
MANAGED-REPO.md is the same rules written for the other side — copy it into a repo you
are about to add.
Set each dev server's own host-check too (Vite's server.allowedHosts, Next's
allowedDevOrigins, and so on). Portable Dev's Host/Origin guard protects its own socket
and nothing else.
A dev server is not hardened software: arbitrary file reads and code execution are ordinary
features of the tooling. Everything inside PORT_RANGE is as reachable as Portable Dev
itself, so the range is one shared authorization boundary — put the identity check at the
gateway and keep the boundary small.
| Path | Response |
|---|---|
/ |
the UI |
/api/servers |
[{ name, description, port, url, icon, up }] — up is a 400 ms TCP connect |
/api/icon/<name> |
that repo's favicon, or 404 |
Requests whose Host is not BIND_ADDR:PORT, localhost:PORT, or 127.0.0.1:PORT get
403, and so do requests carrying a foreign Origin. That is what closes DNS rebinding.
The manifest and service worker are in place, but http://10.99.99.1:<port> is not a
browser secure context — only localhost, 127.0.0.0/8, and ::1 are. Service workers do
not register there, so over a plain-HTTP tunnel the page works as an ordinary web page and
iOS "Add to Home Screen" gives a bookmark rather than an installed PWA.
It installs properly wherever the page is a secure context: localhost during development,
or behind your own TLS terminator. Nothing else in the app depends on it.
| Variable | Default | Meaning |
|---|---|---|
BIND_ADDR |
10.99.99.1 |
address every server in this arrangement binds |
PORT |
8788 |
Portable Dev's own port |
PORT_RANGE |
(blank) | the covered range; blank disables the check |
COMPOSE_FILE |
process-compose.yaml |
the registry |
WEB_DIR |
web/dist |
built UI |
make setup additionally needs CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID,
USER_EMAIL, TEAM_NAME and APP_NAME — see .env.example. The Makefile exports only
the first group to recipes, so the API token never reaches cargo, aube, or any dev
server process-compose starts.
make reads these from .env; the binary itself reads only the process environment. Run
it through make run, a systemd unit, or process-compose — a bare ./portable-dev sees no
.env, so PORT_RANGE is unset and the range check silently does nothing.
make check # test, then lint, then format — the whole gate
make test # cargo test + vitest
make lint # clippy -D warnings + biome lint
make format # cargo fmt --check + biome format
make fix # the write half: cargo fmt + biome check --write
cd web && aube run devEach of test, lint, and format covers both sides; -src and -web suffixes run one
alone (make test-web). Every run tees its full output to .tmp/<target>.log, so a failure
can be read back without re-running it. check never edits the tree — fix is the only
target that writes.
The web side uses aube, not npm — aube ci in make build,
aube run <script> everywhere else.
The UI fetches /api/servers, so astro dev alone shows the error state — run make run
alongside it, or point Astro's dev server at it.
MIT