@dev-ahmed/run-stack
v0.8.1
Published
One command to boot a full local stack in Docker: API, Vite apps, Expo mobile, desktop renderer, database, mail and a status dashboard.
Maintainers
Readme
run
One command to boot a full local stack: a Laravel or Node API, a pnpm monorepo of Vite apps, an Expo mobile client, an Electron or Tauri desktop app, Postgres or MySQL, Redis, Mailpit and MinIO — plus a status dashboard that shows what is up, tails logs, restarts containers, clears caches, launches the iOS Simulator, and deploys web, mobile and desktop.
Which of those actually run is a per-workspace choice: run-stack init asks
what this project needs and which host ports to use, and writes
.run/run.config.json plus a gitignored .run/.env.
Everything runs in Docker. Nothing is installed on the host except Docker itself (and Xcode / Android Studio if you develop the mobile app).
rst init # pick capabilities and ports, write .run/run.config.json
rst up # start everything
rst dash # open the dashboard
rst logs web # follow one service
rst deploy web # ship it
rst down # stop everything, keep the data
rst down web admin dashboard # or stop just those services
rst commands # list every CLI commandrst is a short alias for run-stack (same binary). Both work after a global install.
Shell completion is added to ~/.zshrc on first run-stack / rst use after a global install (pnpm add -g, npm i -g, pnpm link --global). Or run rst completion install.
Releasing
Publishing follows main — no local npm publish, no OTP prompt:
npm version minor # bumps package.json to 0.3.0 and commits
git push # main pushed → 0.3.0 published.github/workflows/publish.yml runs on every push to main and asks one
question: is the version in package.json already on the registry? If it is,
the run passes and does nothing, so ordinary commits stay green. If it is not,
that version is released. Bumping the version is the release. CI on main
also skips when the version did not change (pull requests still always run).
Before the first, add an Automation access token from npmjs.com
(Access Tokens → Generate → Automation) as the NPM_TOKEN repository secret —
automation tokens are the kind that bypass 2FA, which is why this works
unattended.
The workflow refuses to publish unless the shell/python/node checks pass, the
tarball contains no local config (.env, config/credentials.json), and a
copy installed from that tarball can still scaffold a workspace.
It adds --provenance only when this repository is public — npm rejects a
provenance bundle from a private source repo — so releases gain the "built
from this commit" attestation automatically if you ever make it public.
Layout
The runner is installed globally (@dev-ahmed/run-stack). Each project
keeps only its configuration — no run/ copy in the repo:
project/
├── apps/, backend/ # your code (untouched)
└── .run/ # gitignored: run.config.json, .env, compose overlays.run/run.config.json is the answer file from run-stack init. Secrets,
machine-specific ports and generated files also live under .run/, which
run-stack create adds to .gitignore automatically.
Legacy layout — a copied run/ folder next to the app — still works.
Convert it with run-stack migrate (writes .run/run.config.json + .run/.env
and removes the old run/ tree). A root-level run.config.json from an older
config layout is moved into .run/ the next time the workspace is resolved.
Separate repositories, sitting next to each other:
workspace/
backend/ Laravel or Node API
frontend/ pnpm monorepo — apps/* and packages/*
.run/
run.config.json
.envOne monorepo, with config at the root — the API is just another app:
portfolio/
apps/
api/ fastify · prisma → BACKEND_DIR=./apps/api
web/ vite → WEB_APP
mobile/ expo → MOBILE_APP
desktop/ electron → DESKTOP_APP
packages/
.run/
run.config.json
.envIn the second shape BACKEND_DIR and FRONTEND_DIR are both . and
BACKEND_SUBDIR points at the API: the whole repository is mounted, so
workspace symlinks resolve, while backend commands run in the subdirectory. The
wizard finds the API by reading each app's dependencies for a server framework
(express, fastify, @nestjs/core, koa, hono, …), so it does not matter
whether the directory is called api, server or something else.
Both paths are configurable, so any directory names — or absolute paths anywhere on disk — work.
Getting started
Install once, then configure each project from its root:
npm install -g @dev-ahmed/run-stack # or: pnpm add -g @dev-ahmed/run-stack
cd ~/workspace/my-project
run-stack create # writes .run/run.config.json, .run/.env, and .gitignore entry
run-stack up # any other command is forwarded to the global runnerrun-stack migrate converts an existing run/ folder to the config layout.
run-stack upgrade refreshes a legacy run/ copy in place (config layout:
update the global package with run-stack self-update).
Already have a run/ folder? From the project root:
run-stack migrate # run/ → .run/run.config.json + .run/.env
run-stack upOr clone this repository to develop the runner itself:
git clone [email protected]:dev-ahmed/run.git
cd run
./run.sh init # checkout layout: writes run.config.json in cwd
./run.sh upThe setup wizard
run-stack init (or ./run.sh init in a checkout) looks at what is actually
on disk and offers it, so nothing that reaches docker compose has to be
typed from memory. Arrow keys move, Enter selects, and Escape drops back to
typing when the answer is not listed:
Web app
@acme/backoffice · apps/admin · vite
❯ @acme/storefront · apps/web · vite
@acme/marketing-site · apps/shop · astro
↑↓ move · enter select · esc type a valueCapabilities are one screen of checkboxes — space toggles, Enter confirms — pre-ticked from what the scan found:
What should this stack run?
❯ ◉ Admin app
◉ Landing app
◉ Mobile · Expo Metro
○ Desktop renderer
◉ Queue worker
◉ Redis
○ MinIO · S3-compatible storage
↑↓ move · space toggle · enter confirmOutside a terminal — a pipe, --yes, --file, CI — the menus fall back to
numbered prompts and defaults, so scripted runs behave exactly as before.
What it reads, and what it concludes:
| Source | Suggestion |
| --- | --- |
| run/'s own directory and its siblings (legacy) or the workspace root | BACKEND_DIR, FRONTEND_DIR — an artisan file means Laravel, a pnpm-workspace.yaml / workspaces key / apps/ directory means a monorepo |
| each apps/*/package.json's dependencies | where the API is: express, fastify, @nestjs/core, koa, hono, @adonisjs/core and friends mark the API app, which becomes BACKEND_DIR + BACKEND_SUBDIR |
| each apps/*/package.json | the workspace name for WEB_APP, ADMIN_APP, LANDING_APP, MOBILE_APP, DESKTOP_APP — which is what pnpm --filter needs, and is often not the directory name |
| those files' dependencies | which app is which: expo / react-native → mobile, electron / @tauri-apps/* → desktop (and which DESKTOP_STACK), vite / next / astro → web |
| each app's scripts | WEB_CMD, ADMIN_CMD, LANDING_CMD, DESKTOP_CMD, MOBILE_CMD — apps disagree here, so a Vite app gets its dev script and an Expo app its start script. Left empty when the app has neither, and the container's built-in default is used |
| the API's dependencies | the ORM, and with it the real migrate/seed commands: prisma migrate deploy, drizzle-kit migrate, typeorm migration:run, knex migrate:latest, sequelize-cli db:migrate |
| the backend's package.json scripts | candidates for BACKEND_START_CMD (preferring dev, then start, then serve) |
| prisma/schema.prisma | DB_ENGINE, from the datasource provider |
| the workspace directory name | COMPOSE_PROJECT_NAME |
| listening sockets on the host | ports — a clash is rejected and the next free port suggested |
Names are matched by intent, not position: the app called marketing-site
becomes the landing default, backoffice the admin default. Anything the scan
misses can still be typed, and a prompt with no candidates falls back to a
plain question. Every remaining default comes from .env.example, so pressing
Enter through the whole wizard reproduces the stock stack.
It also runs unattended:
run-stack init --yes # every default, no prompts
run-stack init --file .run/run.config.json # answers from a file
run-stack init --force # overwrite an existing configAn answer file is JSON whose keys match .run/run.config.json / .run/.env; anything
it omits is still prompted for (or defaulted, with --yes). See
config/run.config.example.json.
The first real run installs backend and pnpm dependencies inside the containers, which takes a few minutes. They are cached in named volumes afterwards, so later starts are fast.
Services
| Service | Default URL | What it is |
| --- | --- | --- |
| dashboard | http://localhost:8090 | Status board for the whole stack |
| backend | http://localhost:8000 | Laravel or Node API |
| queue, scheduler | — | Background worker and scheduled tasks |
| web, admin, landing | :5173, :5174, :5175 | Vite dev servers |
| mobile-client | http://localhost:8081 | Metro (Expo or bare React Native) |
| desktop | http://localhost:5176 | Electron / Tauri renderer (shell runs on the host) |
| frontend-deps, mobile-deps | — | One-shot dependency installs |
| frontend-packages, mobile-packages | — | Rebuild workspace packages on change |
| frontend-sync | — | Reinstall + rebuild when workspace manifests change on the host |
| postgres | localhost:5434 | Postgres 16 |
| mysql | localhost:3307 | MySQL 8 |
| redis | localhost:6380 | Redis 7 |
| mailpit | http://localhost:8025 | Catches all outgoing mail |
| minio | http://localhost:9001 | S3-compatible object storage |
Every port is configurable, and every non-essential service can be switched
off: RUN_ADMIN, RUN_LANDING, RUN_MOBILE, RUN_DESKTOP, RUN_QUEUE,
RUN_SCHEDULER, RUN_REDIS, RUN_MAILPIT, RUN_MINIO. The database is
DB_ENGINE=postgres | mysql | none — only the chosen engine starts, and only
it appears on the dashboard.
Dashboard
http://localhost:8090. Every card carries the same actions, disabled where a service does not support one:
Open the service's URL · Restart its container · Logs in a drawer
that refreshes every 5s · Clear cache (wipes that service's build caches,
then restarts it) · Shell copies a docker exec line · Deploy on the
web, mobile and desktop cards, which asks for an environment first and runs
exactly what ./run.sh deploy runs.
The mobile card also launches the iOS Simulator, an Android emulator or a USB device through a small LaunchAgent on the host, since Docker cannot.
Desktop
The renderer is a Vite app in the same monorepo and runs in Docker like the other apps. The Electron or Tauri shell needs a GUI, so it runs on the host:
./run.sh desktop # launches the shell against http://localhost:5176Set DESKTOP_STACK=electron|tauri, RUN_DESKTOP=true and DESKTOP_APP. The
shell command defaults to pnpm --filter <app> exec electron . (or tauri
dev); override it with DESKTOP_HOST_CMD. The renderer URL is exported to it
as DESKTOP_RENDERER_URL.
Deploy
./run.sh deploy web # to DEPLOY_DEFAULT_ENV
./run.sh deploy mobile production
./run.sh deploy desktopEach target picks a preset command, which DEPLOY_<TARGET>_CMD can replace
outright:
| Target | DEPLOY_*_TARGET | Runs |
| --- | --- | --- |
| web | vercel | vercel deploy --yes |
| web | netlify | netlify deploy --dir=dist |
| web | docker | builds and pushes DEPLOY_WEB_IMAGE |
| web | ssh | builds, then rsyncs dist/ to DEPLOY_WEB_SSH_DEST |
| mobile | eas | eas build --platform $DEPLOY_MOBILE_PLATFORM --profile $DEPLOY_ENV |
| desktop | electron-builder | builds installers into DEPLOY_DESKTOP_OUT |
| desktop | tauri | tauri build |
Commands run in the frontend container with DEPLOY_ENV and DEPLOY_TARGET
exported, so they see the workspace and its installed dependencies. Any
credentials they need (Vercel, Expo, a registry, an SSH key) come from your
environment — put them in docker-compose.override.yml.
Commands
Setup
init [--file f] Write .run/run.config.json and .run/.env: capabilities, apps, ports
init --update Find apps added to the workspace since the last run and
add them; says so when there is nothing new
Lifecycle
up [service...] Build if needed and start the stack (default command)
down [service...] Stop everything, or just the named services (keeps data)
restart [--build] [service...]
Stop then start (down + up). --build rebuilds images
rebuild Rebuild images from scratch and recreate containers
clean Stop and DELETE all volumes (db, redis, node_modules)
Inspection
commands [--raw] List every CLI command in a table
apps List configured apps (backend, web, mobile, …)
list Alias for apps
services [--raw] List compose services (and status when Docker is up)
ps Service status
ports [--show] Host ports + env URLs table (keep base URLs in sync)
logs [service...] Follow logs
shell [service] Shell into a container (default: backend)
Backend
backend <args...> Run any command inside the backend container
migrate Run pending migrations
seed Run database seeders
fresh Rebuild the schema and seed it (destroys data)
artisan <args...> php artisan ... (BACKEND_STACK=laravel)
composer <args...> composer ... (BACKEND_STACK=laravel)
Frontend
pnpm <args...> pnpm ... inside the frontend workspace
Mobile
ios Open the mobile app in the iOS Simulator
android Open it on an Android emulator
device Open it on a USB phone, or print the Expo Go URL
mobile [--build] Restart mobile-deps / mobile-packages / Metro
prebuild [args...] expo prebuild (Expo apps only)
sync-mobile-port Sync MOBILE_CLIENT_PORT into the mobile app
bundler [ios|android] Point the simulator at Metro
Desktop
desktop Launch the Electron / Tauri shell on the host
Deploy
deploy <target> [env] Deploy web, mobile or desktop
Dashboard
dash Open the status dashboard in a browserConfiguration
Stack settings live in .run/run.config.json (gitignored with the rest of
.run/), written as one object per section:
{
"project": { "COMPOSE_PROJECT_NAME": "myapp" },
"repositories": { "BACKEND_DIR": "./backend", "FRONTEND_DIR": "./frontend" },
"backend": { "BACKEND_STACK": "laravel" },
"apps": { "WEB_APP": "web", "EXTRA_APPS": "reports" },
"ports": { "BACKEND_PORT": 8000, "WEB_PORT": 5173 }
}The sections are for reading — every key keeps its name, and a flat object of
the same keys still loads, which is the easier shape to write by hand for
init --file. A config written by an older version is flat; run-stack init
--update upgrades it in place, keeping every value, and says so when it does. Runtime overrides and secrets are merged into .run/.env.
.env.example in the package is the full annotated list; the essentials are:
| Variable | Purpose |
| --- | --- |
| COMPOSE_PROJECT_NAME, PROJECT_LABEL | Compose project, image names, dashboard title |
| BACKEND_DIR, FRONTEND_DIR | Where the app repositories live (may be the same one) |
| BACKEND_STACK, BACKEND_SUBDIR | laravel or node, and where the API sits in its repo |
| BACKEND_*_CMD | Install / build / start / migrate / seed commands for a Node backend |
| WEB_APP, ADMIN_APP, LANDING_APP, MOBILE_APP, DESKTOP_APP | Workspace names of the apps to run |
| RUN_* | Turn any service off (ADMIN, LANDING, MOBILE, DESKTOP, QUEUE, SCHEDULER, REDIS, MAILPIT, MINIO) |
| *_PORT | Host ports for every service |
| DB_ENGINE, DB_*, RUN_MIGRATIONS, RUN_SEEDERS | Database engine, credentials and startup behaviour |
| DESKTOP_STACK, DESKTOP_HOST_CMD | Electron or Tauri, and how to launch the shell |
| DEPLOY_*_TARGET, DEPLOY_*_CMD, DEPLOY_ENVS | What ./run.sh deploy and the Deploy buttons run |
| MINIO_* | Object storage credentials and bucket |
| APP_KEY, APP_ENV, APP_DEBUG | Laravel bootstrap |
| VITE_*, EXPO_PUBLIC_API_BASE_URL | How the browser / device reaches the API |
| BACKEND_HEALTH_PATH, BACKEND_LOGIN_PATH | What the dashboard probes and posts to |
| EXTRA_APPS | Other workspace apps to serve, beyond the roles above |
| EXTRA_SERVICES, COMPOSE_PROJECTS | Other compose stacks to show on this dashboard |
Two things to know:
- The run scripts source
.run/.envwith bash, so quote any value containing spaces or shell characters:PROJECT_LABEL="My App". - Machine specific compose tweaks go in
.run/docker-compose.override.yml(gitignored under.run/), merged automatically.
Adding an app later
A monorepo usually grows an app or two after the first setup. Adding one does not mean redoing it:
run-stack init --update--update does not start the setup over, and does not ask you to name
anything. It scans the workspace, lists the apps that are not configured yet,
and adds them. There is no snapshot of a previous state to go stale: the
config is the record of what is already known, so an app is new when it is in
the workspace and not in the config. An app that has gone the other way — in
the config, no longer in the workspace — is dropped. Everything else — project, backend, database, existing ports,
deploy targets — keeps the answers it already has. With nothing new to add it
says so and stops:
$ run-stack init --update
No new apps — every app in ./apps is already configured.The apps it adds land in EXTRA_APPS:
"EXTRA_APPS": "reports partner-portal",
"REPORTS_PORT": 5180They are not a special kind of app: each one gets the same container, the same
install and the same dev server as web, admin and landing. An app that
depends on Expo or React Native is run as Metro instead — its own
package.json decides that, nothing in the config. The name is the workspace
name from its apps/<dir>/package.json, and it is also its compose service, so
run-stack logs reports works straight away. Ports are assigned and
preflighted like any other, and the apps show up in run-stack apps,
run-stack ports and the dashboard.
One monorepo
If the API and the frontend live in a single repository, point both paths at the repository root and say where the API is:
BACKEND_DIR=./mono
FRONTEND_DIR=./mono
BACKEND_SUBDIR=apps/api # commands run here; the whole repo is mountedThe repository is mounted once at /app, and the backend container works in
/app/apps/api. Mounting the subdirectory alone would not work: pnpm links
workspace dependencies to the repository root, and those symlinks would point
outside the mount.
When both stacks are node and share the repo, the backend also shares the
frontend's node_modules volumes and waits for frontend-deps, so there is
one pnpm install for the whole workspace rather than two competing ones:
BACKEND_STACK=node
BACKEND_INSTALL_CMD=none # frontend-deps already installed the workspace
EXTRA_DEPS_APPS=api # so that install covers the API toorun-stack init fills EXTRA_DEPS_APPS with the API's workspace name on its
own when it shares the repo. Leaving it out is the classic monorepo failure:
frontend-deps installs a filtered subset of the workspace, pnpm leaves every
unselected project unlinked, and the API starts against a node_modules with
its own workspace packages missing.
A Laravel API inside a JS monorepo works the same way, minus those two lines —
Composer keeps vendor/ in the bind mount, so there is nothing to share.
Backends
BACKEND_STACK=laravel builds docker/backend/laravel (PHP 8.4, Composer,
artisan). It waits for Postgres, installs vendor/, generates APP_KEY if
the app has none, links storage, runs migrations and — only against an empty
database — the seeders. It then serves Laravel's own router script directly
rather than artisan serve, which forwards only a whitelist of environment
variables to its workers and would drop the overrides compose sets.
BACKEND_STACK=node builds docker/backend/node (Node 22, pnpm/npm/yarn)
and runs the commands you give it, so it fits Express, Fastify, NestJS,
AdonisJS or anything else:
BACKEND_STACK=node
BACKEND_DIR=../api
BACKEND_START_CMD="pnpm dev" # must listen on $PORT
BACKEND_MIGRATE_CMD="npx prisma migrate deploy"
BACKEND_SEED_CMD="npx prisma db seed"
BACKEND_FRESH_CMD="npx prisma migrate reset --force"
RUN_QUEUE=false
RUN_SCHEDULER=false.env.node.example has the complete set. Notes:
BACKEND_INSTALL_CMDdefaults to whatever the repository's lockfile implies (pnpm install --frozen-lockfile,yarn install,npm ci, ornpm install).- Dependencies install into a named volume, so host and container
node_modulesnever mix — a native module built on macOS will not break the Linux container. - The container gets
DATABASE_URL,REDIS_URLandPORTalongside the individualDB_*/REDIS_*/MAIL_*variables, so most apps need no changes. The app must listen on0.0.0.0:$PORT, not127.0.0.1. - Seeders run once per volume (a marker file under
/run-state), because seed scripts are rarely idempotent../run.sh cleanresets it. - Leave
BACKEND_QUEUE_CMD/BACKEND_SCHEDULE_CMDempty and setRUN_QUEUE=false/RUN_SCHEDULER=falseif the app has no workers.
./run.sh backend <cmd> runs anything inside the container on either stack;
artisan and composer are Laravel-only and say so if the stack is Node.
Frontend
The monorepo is bind-mounted, so edits on the host hot-reload in the containers. What the runner expects of it:
- a pnpm workspace with packages under
apps/*andpackages/* - each app named in
.envresolvable as a pnpm filter (WEB_APP, …) - shared packages with a
buildscript and a watch-modedevscript - Vite for the web apps, Expo or bare React Native for the mobile app
Only the enabled apps and their dependencies are installed and built, so
turning RUN_ADMIN or RUN_MOBILE off genuinely saves install time.
Every node_modules directory inside the monorepo is shadowed by a named
volume so host-built dependencies never leak in. That list depends on the
packages the workspace actually has, so ./run.sh up regenerates
docker-compose.packages.yml from disk before starting — it is generated
output and git-ignored.
Package edits reach the running apps through two watchers:
frontend-packagesrebuilds each package'sdist/as its source changes, then restarts the app dev servers once the rebuild settles — a Vite server only watches its own root, so without that nudge it would keep serving the previously transformed module forever.frontend-syncreruns the install whenever apackage.jsonorpnpm-lock.yamlchanges on the host, since a new dependency edge can only appear inside the node_modules volumes through an install run in Docker.
The intervals are tunable with DEPS_SYNC_INTERVAL, DIST_RESTART_INTERVAL
and DIST_SETTLE_SECONDS in run.config.json.
Dashboard
http://localhost:8090 by default. It reads container state from the Docker
socket (mounted read-only — it reports, it never controls the engine), probes
every service over the compose network, and tails per-service logs in a drawer.
On macOS it can also boot the iOS Simulator or an Android emulator, which a
container cannot do, by asking a small LaunchAgent running on the host.
Set EXTRA_SERVICES to put other compose stacks on the same board, so one page
covers everything you run locally:
EXTRA_SERVICES="other/api|Other API|4000|/health;other/web|Other web|3000|/"
# project/service|label|host port|health path, joined by ";"The credentials panel is optional: copy config/credentials.example.json to
.run/credentials.json and describe your seeded accounts. ${VAR}
placeholders are filled from the backend's own .env, so the panel shows what
the seeder actually used, and each account's login object is posted verbatim
to BACKEND_LOGIN_PATH when you press Test login — which keeps it working
whatever your auth schema looks like. The file is git-ignored.
Mobile
Metro runs in Docker; the Simulator, emulator and native builds run on the host.
./run.sh ios / android / device relaunch an already installed build when
IOS_BUNDLE_ID / ANDROID_PACKAGE are set, and otherwise fall back to
expo run:* or react-native run-* on the host (which needs the host's own
pnpm install for native tooling).
For Expo apps, regenerate the native projects with run-stack prebuild (passes
through args like --clean). That re-syncs MOBILE_CLIENT_PORT into the new
ios / android folders so the next ios / android rebuild picks up Metro.
Because a phone cannot reach localhost, the runner fills
REACT_NATIVE_PACKAGER_HOSTNAME and EXPO_PUBLIC_API_BASE_URL with this
machine's LAN IP unless you set them yourself.
Docker Development
This stack Dockerizes the JavaScript side of React Native — Node.js, pnpm, Metro, and workspace dependencies — while Xcode, the iOS Simulator, Android Studio, and the Android Emulator stay on the macOS host.
The runner auto-detects Expo vs bare React Native from the mobile app's
package.json (expo in dependencies → Expo; react-native only → bare RN).
Override with MOBILE_STACK=expo or MOBILE_STACK=react-native in
run.config.json.
Architecture
macOS host
├── React Native source (bind-mounted into /app)
├── Xcode / iOS Simulator
├── Android Emulator
│
└── Docker
├── Node.js 22 + pnpm
├── Metro (mobile-client service)
└── node_modules in named volumes (never mixed with host)
│
└── localhost:8081Start Metro
From the project root:
run-stack up # full stack (API, web, Metro, …)
run-stack up mobile-client # Metro onlyMetro listens on 0.0.0.0:8081 inside the container. Verify from the host:
curl http://localhost:8081/statusStop Metro
run-stack down # stop the whole stack
run-stack restart mobile-client # or restart Metro onlyView Metro logs
run-stack logs mobile-clientRestart Metro
run-stack mobile # mobile-deps + mobile-packages + Metro
run-stack restart mobile-client # Metro onlyOr from the dashboard: Restart on the Mobile client card, or Clear cache to wipe Metro/Expo caches first.
Fast Refresh / hot reload
Edit any .tsx, .ts, or .js file under the frontend repository on the
host. Metro in Docker picks up the change and triggers Fast Refresh in the
simulator or device.
File watching uses polling (CHOKIDAR_USEPOLLING=true,
WATCHPACK_POLLING=true) because Docker Desktop on macOS does not forward
inotify events reliably through bind mounts. Watchman is disabled inside the
container (METRO_DISABLE_WATCHMAN=1).
If edits are not detected, confirm polling is on and restart Metro:
run-stack mobileiOS workflow
Terminal 1 — start Metro:
run-stack up mobile-clientTerminal 2 — build and run on the Simulator (host):
run-stack iosThat runs expo run:ios --no-bundler or react-native run-ios --no-packager
on the host so Xcode stays on macOS while Metro stays in Docker.
After the first install, set IOS_BUNDLE_ID in run.config.json to relaunch without
rebuilding:
xcrun simctl launch booted app.your.bundle.idLimitation: react-native run-ios and expo run:ios must run on the host
(Linux/Docker cannot drive Xcode). You can also open ios/*.xcworkspace in
Xcode and press Run while Metro is up — the Simulator reaches Metro at
http://localhost:8081.
Android workflow
Terminal 1 — Metro in Docker (same as iOS).
Terminal 2 — emulator on the host:
run-stack androidFor an already installed app, set ANDROID_PACKAGE in run.config.json to skip the
rebuild. The script runs adb reverse so the emulator reaches Metro on the
host:
adb reverse tcp:8081 tcp:8081Android Emulator networking: the emulator uses adb reverse to reach
localhost:8081 on the host — no hardcoded IP required. If you start Metro
manually without ./run.sh android, run the reverse command yourself.
Physical Android device (USB): ./run.sh device android-device — USB
debugging must be on; adb reverse is applied automatically when the app is
already installed.
Physical iOS device
./run.sh device ios-deviceSet REACT_NATIVE_PACKAGER_HOSTNAME in run.config.json to your Mac's LAN IP (or
leave it empty and run-stack fills it in). The device and Mac must be on the
same Wi‑Fi network.
node_modules isolation
Every node_modules path in the monorepo is shadowed by a Docker named volume
(fe_nm_*), so macOS-built native modules never leak into the Linux container
(and vice versa). ./run.sh up regenerates the volume list from disk.
Install new npm packages on the host (edit package.json), then let
frontend-sync reinstall inside Docker, or run:
run-stack restart mobile-deps frontend-depsNetworking summary
| Client | Metro URL | Notes |
| --- | --- | --- |
| iOS Simulator | http://localhost:8081 | Same network namespace as macOS |
| Android Emulator | http://localhost:8081 | Via adb reverse tcp:8081 tcp:8081 |
| Physical device | http://<LAN-IP>:8081 | Set REACT_NATIVE_PACKAGER_HOSTNAME |
| API from device | http://<LAN-IP>:8000/api | Set EXPO_PUBLIC_API_BASE_URL |
Troubleshooting
| Symptom | Fix |
| --- | --- |
| Fast Refresh does not trigger | Confirm CHOKIDAR_USEPOLLING=true; restart mobile-client |
| curl localhost:8081/status fails | Check MOBILE_CLIENT_PORT; run ./run.sh logs mobile-client |
| Simulator shows "Could not connect to development server" | Metro not running, or wrong packager host — check REACT_NATIVE_PACKAGER_HOSTNAME |
| Android emulator cannot reach Metro | Run adb reverse tcp:8081 tcp:8081 |
| Host native build fails on deps | Run pnpm --filter <MOBILE_APP>... install once in the frontend repo on the host |
| Stale Metro cache | Dashboard Clear cache on mobile-client, or run-stack mobile |
Running outside Docker
The frontend repository is unchanged — you can still run Metro and native builds entirely on the host with pnpm. Docker is optional and only replaces the JavaScript toolchain.
Troubleshooting
| Symptom | Fix |
| --- | --- |
| port is already allocated | Change the matching *_PORT in .env |
| The API container runs but BACKEND_PORT refuses the connection | The app ignores $PORT and binds its own. The log names the port it bound — put it in BACKEND_CONTAINER_PORT |
| A web app answers on the wrong port, or only inside the container | The app's dev script strips the flags the runner passes; give it a --host/--port of its own, or point WEB_CMD at a script that accepts them |
| Frontend deps look stale or broken | ./run.sh rebuild, or ./run.sh clean to drop the volumes too |
| Backend keeps waiting for Postgres | ./run.sh logs postgres — usually a stale volume from another project |
| A phone cannot reach Metro or the API | Set REACT_NATIVE_PACKAGER_HOSTNAME to your LAN IP explicitly |
| Dashboard shows "Cannot read the Docker socket" | Docker Desktop is not running, or the socket path differs on your setup |
| ./run.sh fails parsing .run/.env | Quote values containing spaces or shell characters |
Requirements
- Docker with Compose v2 (Docker Desktop on macOS)
- bash and python3 on the host (both ship with macOS and most Linux distros)
- macOS or Linux
- Xcode / Android Studio, only for
./run.sh ios/android
Assumptions
Postgres and Redis are assumed, and the frontend is assumed to be a pnpm
workspace of Vite apps plus an optional Expo app. The backend is either Laravel
or Node. Ports, paths, app names, commands and which services run are all
configurable; a different database, or a frontend that is not a pnpm workspace,
means editing docker-compose.yml.
License
MIT
