Running host applications with localghost¶
localghost run lets you serve an application behind the hub without a
Dockerfile or Compose file, or hand an existing Compose project to
docker compose up with the hub wired in first.
Project types¶
Every project has a type. Detection runs automatically from the selected
directory; --type resolves ambiguity or skips detection entirely.
| Type | Detection | Default port |
|---|---|---|
| compose | compose.yaml, compose.yml, docker-compose.yaml, or docker-compose.yml |
— (Compose owns it) |
| django | manage.py in the project directory |
8000 |
| vite | package.json with a dev script and vite dependency |
5173 |
| astro | package.json with a dev script and astro dependency |
4321 |
| cakephp | bin/cake with a cakephp/cakephp Composer dependency, or a legacy app/Config/core.php + app/webroot/index.php root |
8765 |
| laravel | artisan with a laravel/framework Composer dependency |
8000 |
| php | composer.json, or index.php in public/, web/, htdocs/, www/, or the project root |
8080 |
dockerfile is an eighth type, but it is save-only — see
Saving project setup. Running a Dockerfile
project first needs localghost save --type dockerfile to produce a
Compose file; after that the project is compose.
Within PHP, cakephp and laravel are specializations of php rather than
peers: the most specific match wins, and generic php only fires when neither
framework matches. Every other type resolves to the nearest match along the
upward search.
If more than one type is detected at the same directory (for example, a
Django project that also has a package.json with Vite, or a directory
holding both a Compose file and a framework), use --type to resolve the
ambiguity:
Localghost searches upward from the selected directory to the nearest project
root, stopping at the search boundary described below. This means it can be
run from directories such as webroot, public, or a nested source package
without naming the application after that directory. The detected root
supplies the default public name and application configuration; a framework
may still use a different working directory for its server process.
Usage¶
Run your app; Localghost starts the hub when needed:
A compose type hands the project straight to docker compose up after
starting the hub. Because Compose owns the application's configuration, the
host-only options (--type for a framework, --port, and a -- command)
are rejected when a compose type is selected or detected:
Before starting anything, a Compose run checks that the project is actually
wired to the hub — the localghost network is present, and at least one
service carries traefik.enable=true on that network. A project that has not
saved its setup is refused with an exact localghost save next action, rather
than starting and printing a URL that would never route. save --run
runs the same check before starting, so a misconfigured save is caught
immediately rather than deferred to the next run. A saved
type = "compose" resolves ambiguity but never bypasses this routing check.
Configured runs¶
Projects may keep repeatable run settings in .localghost.toml:
Or, to run something other than the type's own server:
| Key | Type | Meaning |
|---|---|---|
type |
one of the seven types run --type accepts (not dockerfile, which is save-only) |
Resolves otherwise ambiguous detection, exactly like --type. Detected when omitted. |
name |
string | Public name, serving the app at NAME.localhost. Defaults to the project root's name. |
root |
path, relative to the config file | Treat this directory as the project root instead of searching. See Project root and configuration discovery. |
port |
integer, 1–65535 | Host HTTP port. The type's default is used when omitted, except alongside command, where it is required. |
command |
array of strings | Argv to run instead of the type's own server. {port} is replaced with the selected port. |
Setting command makes the run a custom one: the type is reported as custom,
type has no effect, and port becomes required because there is no framework
default to fall back on. Set one or the other, not both.
Every key is optional, and an unrecognised key is an error rather than being
ignored — a misspelled setting fails loudly instead of silently doing
nothing. [run].framework is still accepted as a deprecated alias for type
and prints a warning; [run].mode is rejected outright, since mode has no
direct equivalent — the error names the migration (type = "compose", or
remove the key and let detection choose).
Settings are layered: a command-line option wins over .localghost.toml,
which wins over automatic detection. Use --config PATH to read a different
file.
localghost save writes this file without running, while save --run writes
the same setup and then starts it:
uvx localghost save --port 8080 -- ./server --port 8080
uvx localghost save --port 8080 --run -- ./server --port 8080
It refuses to overwrite an existing .localghost.toml unless --extend is
given, keeps a .bak copy when it does rewrite one, and prints the
configuration without writing anything under --dry-run.
Project root and configuration discovery¶
The project root supplies the hostname and anchors configuration. Resolution tries each of the following in order, and the first match wins:
--project-root PATH— resolved relative to the process working directory.[run].rootin a discovered.localghost.toml— resolved relative to the directory holding that config file.- The directory holding the discovered
.localghost.toml, when one is found but sets noroot. - The nearest ancestor of
-C/--directory(inclusive) at which a type is detected — the default, unbounded-by-config search.
Both --project-root and [run].root accept .., so a config file at
myrepo/tools/.localghost.toml can set root = ".." to point at myrepo.
When the root is pinned by one of the first three rules, type detection runs
only at that directory rather than walking upward; a pinned root with no
detectable type is an error naming the available types.
.localghost.toml itself is discovered by the same upward search --type
detection uses, but only once that search has found a VCS marker. Without
one, config discovery does not walk at all: only the invocation directory
(-C) is a candidate, because [run].command is arbitrary argv that run
executes, so a config file must be inside a project boundary to be trusted.
--config PATH overrides discovery entirely, and that file's directory then
anchors the root under rule 3.
Search boundary. Type detection walks upward from -C, stopping after
the first directory containing .git, .hg, or .svn — that directory is
itself included as a candidate — and never reaches $HOME or above: even
when $HOME holds a VCS marker, $HOME itself and everything above it,
including the filesystem root, are never candidates. Config discovery uses
that same bounded walk when a VCS marker is present, but — as above —
does not walk at all without one. A stray ~/package.json therefore cannot
be adopted as a project's type, and a forgotten ~/.localghost.toml cannot
be adopted as its config.
The status bar¶
A foreground localghost run pins the public URL to the bottom row of the
terminal, so it stays visible while the application's own output scrolls
above it:
The bar appears immediately, before the hub is reconciled, and names the step
it is waiting on — starting hub while the hub comes up, then starting once
the application's own process has been launched.
The spinner and the dimmed URL mean the application is not answering yet. Localghost probes it — a host application on its port, a Compose project through the route itself — and the bar switches to a solid URL once the first probe succeeds, so the bar doubles as the readiness signal rather than inviting a click that would return a gateway error.
The probe resolves localhost and tries every address it yields, so a server
that bound only the IPv6 loopback is seen. On a native Linux daemon an
application answering only on loopback is not reported ready until the relay
that makes it reachable is up (see
Loopback-bound servers).
If something is already serving on the application's port when the run
starts, Localghost refuses to start rather than launching an application onto
a URL that would serve whatever was already there. On Linux the error names
the process holding the port and its working directory, and it always says
how to get past it: stop that process, or choose another port with --port.
The bar is drawn with a terminal scrolling region that covers every row except
the last, which leaves the terminal's own scrollback intact: scrolling up
still reaches everything the application printed. This holds inside terminal
multiplexers as well — it has been checked against VTE-based terminals, tmux,
and zellij, in each case reaching the first line the application printed. It is skipped when output is
not a terminal, when TERM is unset or dumb, when the window is narrower
than 40 columns or shorter than 3 rows, and on --detach and --dry-run
runs. Pass --no-status-bar to turn it off:
Turning the bar off, or piping the output, does not turn off the watching. Whether the application came up somewhere the hub can reach is a fact about the run, so the relay and the unreachable-application diagnosis below happen either way.
Detached runs¶
--detach records the process outside the project directory and keeps its
output in Localghost's state directory:
localghost run --detach
localghost sessions list
localghost sessions list --json
localghost sessions logs SESSION_ID
localghost sessions logs SESSION_ID -f
localghost sessions stop SESSION_ID
localghost sessions stop --all
Session records live in ${XDG_STATE_HOME:-$HOME/.local/state}/localghost/sessions
(or $LOCALGHOST_STATE_DIR when set). A host session's liveness is probed by
its recorded process ID and a Compose session's by docker compose ps.
sessions logs reads a host run's captured file and delegates Compose runs to
docker compose logs; add -f to keep following either kind of session.
sessions stop asks a host process to exit with SIGTERM, then force-quits it
with SIGKILL after a two second grace period; it reports an error and keeps
the record if the process somehow survives. localghost sessions clean
removes records and bridges left by sessions that already exited, leaving
running ones alone. localghost hub down continues to control only the hub.
The app is available at https://my-django-project.localhost. Press Ctrl+C to
stop it.
Custom port¶
Override the detected port when the default is already in use:
A port counts as free only when it can be bound over both IPv4 and IPv6. If
it is free it is used directly. If it is occupied and --port was given
explicitly, an error is raised naming what holds it. Without --port, the
next free port is chosen automatically, and a warning says which port was
passed over and, on Linux, which process and working directory hold it. That
matters because an application that ignores the port it is given starts from
its own default, meets the same squatter, and picks its own replacement.
Explicit type¶
Skip auto-detection and specify the type:
--framework is no longer accepted as a CLI option; use --type.
[run].framework in .localghost.toml still works as a deprecated alias for
[run].type — see Configured runs above.
Custom command¶
Run an arbitrary process with a custom port:
How it works¶
For host types, localghost run creates an ephemeral Caddy bridge container
on the localghost network. The Caddy container forwards requests from the
hub to the host process via host.docker.internal, handing on the client
address and scheme Traefik recorded in X-Forwarded-For and
X-Forwarded-Proto rather than its own view of that internal hop. When the
foreground process exits, the bridge is removed automatically.
If a previous localghost run was interrupted and left a stale bridge
container, it is detected and removed automatically before the new one
starts.
For the compose type, the bridge is not a container: it is the routing
labels and localghost network membership on the application's own service,
which localghost save writes.
Django runner detection¶
For Django projects, localghost selects the Python runner in this order:
uv run pythonifuv.lockis presentpoetry run pythonifpoetry.lockpipenv run pythonifPipfileorPipfile.lock- The active
VIRTUAL_ENVpython if set .venv/bin/pythonif the directory exists- Errors if none of the above are found
JavaScript package-manager detection¶
For Vite and Astro projects, localghost run first uses the packageManager
field in package.json when it is present. Otherwise, lockfiles are checked in
this priority order: bun.lock/bun.lockb, pnpm-lock.yaml, yarn.lock, then
package-lock.json/npm-shrinkwrap.json; the first detected manager whose
executable is installed is used. More specific lockfiles are preferred because
they are stronger signals of intentional package-manager use. This makes
projects with more than one lockfile deterministic while allowing an
unavailable higher-priority manager to fall back to another detected manager.
Declare packageManager in
package.json when a specific manager must be used; a declared manager is not
silently replaced if its executable is missing. Localghost does not inspect
lockfile contents or timestamps, so the priority is a fallback heuristic rather
than proof of which lockfile is current.
PHP runner detection¶
Modern CakePHP applications run bin/cake server from the application root.
When bin/cake is not executable, Localghost uses php bin/cake.php if that
entry point is present. Legacy CakePHP 2 applications run PHP's development
server from app/webroot, while their hostname remains derived from the
application root. Laravel applications run php artisan serve from the
application root. Plain php projects run PHP's built-in server
(php -S 0.0.0.0:{port}) from their docroot — the first of public/,
web/, htdocs/, www/ containing index.php, otherwise the project root
itself.
PHP framework detection requires both framework-specific files and Composer
dependency metadata where modern projects provide it. A generic
composer.json, public, or webroot directory alone selects the generic
php type, not a specific framework runner.
A bare index.php at a directory with nothing else is treated as a probable
docroot rather than a project root: the search continues upward, and that
directory is used only if nothing stronger is found above it. This keeps
app/webroot from shadowing a CakePHP root one level up, while a
self-contained services/billing/composer.json inside a monorepo still stays
at billing rather than climbing to an unrelated top-level manifest.
Wrapper scripts¶
Localghost passes --port through the package manager, with --strictPort for
Vite, so the dev server takes the port the bridge points at rather than
wandering off it. Where no relay is available it also passes --host 0.0.0.0
(see Loopback-bound servers). If the dev script
calls another script, that wrapper must forward the arguments or they are
silently discarded, and localghost warns when it can see a dev script that
does not invoke the tool itself.
For example:
The wrapper should pass its arguments to the nested package script:
The "$@" passes Localghost's arguments through to Vite. Without it the server
starts on its own default port, and binds loopback where localghost would have
asked for a wider interface.
A server that comes up on another port anyway is not left behind a URL that cannot resolve. On a native Linux Docker daemon the run relays the planned port to it when it can tell which listener is the application; otherwise it stops with an error naming every address the application is listening on (see troubleshooting).
Loopback-bound servers¶
The bridge reaches the host over the Docker gateway address, which a server
bound to 127.0.0.1 or [::1] does not accept. On Linux, run notices when the
application comes up on loopback alone and relays to it from the gateway address
only, on the same port, so the bridge reaches it while the LAN does not. Because
that relay can stand in for a wider bind, localghost does not ask Vite or Astro
for --host 0.0.0.0 where one is available.
When a relay starts, the run says so: Application bound 127.0.0.1:5173;
relaying from 172.17.0.1:5173 so the hub can reach it.
Where no relay can be raised, localghost passes --host 0.0.0.0 instead. On
Linux with a native Docker daemon, an application that still comes up on
loopback is then stopped, and the run ends with an explanation rather than
leaving it waiting behind a URL that cannot resolve.
A relay needs the Docker gateway to be an address of the host itself, which it
is only for a native daemon. Docker Desktop for Linux and rootless Docker report
a gateway that lives inside a VM or a user namespace, so no relay is raised
there and Vite and Astro are still asked for --host 0.0.0.0. Those
installations proxy the bridge's route to the host through a process that
reaches loopback, so a loopback bind is left alone rather than refused.
Notes¶
- The host process must be reachable from the Caddy bridge: on a Docker-reachable
interface, or on loopback where
runcan relay to it (see Loopback-bound servers). - The bridge container uses the pinned
caddy:2.11.4-alpineimage. - Django's
ALLOWED_HOSTSandCSRF_TRUSTED_ORIGINSmay need updating for the.localhosthostname — localghost warns about missing values when possible.