npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

run

ci

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 command

rst 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
    .env

One 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
    .env

In 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 runner

run-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 up

Or 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 up

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

Capabilities 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 confirm

Outside 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 config

An 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:5176

Set 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 desktop

Each 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 browser

Configuration

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/.env with 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": 5180

They 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 mounted

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

run-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_CMD defaults to whatever the repository's lockfile implies (pnpm install --frozen-lockfile, yarn install, npm ci, or npm install).
  • Dependencies install into a named volume, so host and container node_modules never mix — a native module built on macOS will not break the Linux container.
  • The container gets DATABASE_URL, REDIS_URL and PORT alongside the individual DB_* / REDIS_* / MAIL_* variables, so most apps need no changes. The app must listen on 0.0.0.0:$PORT, not 127.0.0.1.
  • Seeders run once per volume (a marker file under /run-state), because seed scripts are rarely idempotent. ./run.sh clean resets it.
  • Leave BACKEND_QUEUE_CMD / BACKEND_SCHEDULE_CMD empty and set RUN_QUEUE=false / RUN_SCHEDULER=false if 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/* and packages/*
  • each app named in .env resolvable as a pnpm filter (WEB_APP, …)
  • shared packages with a build script and a watch-mode dev script
  • 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-packages rebuilds each package's dist/ 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-sync reruns the install whenever a package.json or pnpm-lock.yaml changes 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:8081

Start Metro

From the project root:

run-stack up                  # full stack (API, web, Metro, …)
run-stack up mobile-client    # Metro only

Metro listens on 0.0.0.0:8081 inside the container. Verify from the host:

curl http://localhost:8081/status

Stop Metro

run-stack down                # stop the whole stack
run-stack restart mobile-client   # or restart Metro only

View Metro logs

run-stack logs mobile-client

Restart Metro

run-stack mobile              # mobile-deps + mobile-packages + Metro
run-stack restart mobile-client   # Metro only

Or 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 mobile

iOS workflow

Terminal 1 — start Metro:

run-stack up mobile-client

Terminal 2 — build and run on the Simulator (host):

run-stack ios

That 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.id

Limitation: 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 android

For 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:8081

Android 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-device

Set 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-deps

Networking 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