Integrating applications with localghost¶
An application joins the hub's external Docker network and describes its routes using Traefik labels. The hub and application remain separate Compose projects.
Prerequisites¶
Start the hub before the first application. This creates the external
localghost network:
Compose automatically derives the project name from the checkout directory and
makes it available to labels as ${COMPOSE_PROJECT_NAME}. Nothing needs to be
configured when that name is unique and contains only lowercase letters,
digits, and hyphens.
Override it only when the derived name is unsafe, duplicated, or not the
hostname you want. For example, set it in .env:
You can instead use the COMPOSE_PROJECT_NAME environment variable or
Compose's project-name option.
Primary service¶
This example publishes a service listening on container port 8000 at
http://my-project.localhost:
services:
web:
networks:
- default
- localghost
labels:
- "traefik.enable=true"
- "traefik.docker.network=localghost"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.entrypoints=web"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.service=${COMPOSE_PROJECT_NAME}-web"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web-secure.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web-secure.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web-secure.service=${COMPOSE_PROJECT_NAME}-web"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web-secure.tls=true"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=8000"
networks:
localghost:
external: true
The application process must listen on 0.0.0.0:8000, not 127.0.0.1:8000,
inside its container. The backend port label is a container port; it is not a
host-published port.
Do not add ports for a service used only through the hub. Keeping the
service off host ports avoids collisions and keeps the hub as the single HTTP
entrypoint. The service can remain on its default network for private
application dependencies.
The router's explicit service label avoids implicit association. The explicit
load-balancer port remains deterministic if the image or Compose definition
later exposes another port.
Secondary services¶
Secondary web interfaces follow <service>.<project>.localhost. For a Mailpit
service listening on container port 8025:
services:
mailpit:
image: axllent/mailpit:v1.27.7 # Choose and review the version used by your project.
networks:
- default
- localghost
labels:
- "traefik.enable=true"
- "traefik.docker.network=localghost"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit.entrypoints=web"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit.rule=Host(`mailpit.${COMPOSE_PROJECT_NAME}.localhost`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit.service=${COMPOSE_PROJECT_NAME}-mailpit"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit-secure.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit-secure.rule=Host(`mailpit.${COMPOSE_PROJECT_NAME}.localhost`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit-secure.service=${COMPOSE_PROJECT_NAME}-mailpit"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-mailpit-secure.tls=true"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-mailpit.loadbalancer.server.port=8025"
networks:
localghost:
external: true
For a checkout named my-project, the URL is
http://mailpit.my-project.localhost.
Repeat the pattern for other browser-facing tools. The service segment and the router/service identifiers must be distinct within the project.
Optional HTTPS¶
Each example defines two routers for the same hostname and backend service. The
ordinary router uses the always-available web entrypoint. The -secure router
uses websecure with TLS and becomes active after localghost trust install
enables the hub's HTTPS configuration. Keeping both routers means HTTP
continues to work after HTTPS is enabled.
localghost save adds both routers automatically. For hand-written integration,
include all four -secure labels shown above: entrypoint, rule, service, and
tls=true. A secure router without an explicit service label can be associated
with the wrong backend when a container defines multiple routers or services.
Install mkcert, then enable and inspect trust with:
Use https://<project>.localhost for the primary service and
https://<service>.<project>.localhost for secondary services. HTTP remains
available at the corresponding http:// URLs. See
Operating localghost for trust-store,
port, and removal details.
Multiple checkouts¶
Two checkouts of the same repository can use the same Compose configuration:
checkout primary URL
../my-project http://my-project.localhost
../my-project-review-123 http://my-project-review-123.localhost
Compose project names also isolate container and default-network names. Stopping one checkout leaves the other checkout and the hub running. Override a name only if two checkout directories have the same basename or a basename is not DNS-safe.
Framework configuration¶
Traefik routing does not bypass application security checks. Configure generated hostnames in trusted-host, origin, CORS, callback URL, and cookie settings as required by the framework.
Django¶
Routing through the hub has three separate consequences for a Django project. Each one fails differently, and each one is easy to mistake for a localghost problem:
ALLOWED_HOSTS = [".localhost"]
CSRF_TRUSTED_ORIGINS = ["http://*.localhost", "https://*.localhost"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Prefer these wildcard forms over a single hostname. A checkout's public name
follows its directory or COMPOSE_PROJECT_NAME, so a per-hostname list
breaks the moment a project is renamed, copied to a second checkout, or run
by a colleague from a differently named directory — and the failure lands in
the application, far from the rename that caused it. .localhost and
*.localhost are only reachable on the developer's own machine, so nothing
is widened that a browser elsewhere could reach.
ALLOWED_HOSTS — without it every request is a 400 Bad Request
("Invalid HTTP_HOST header"). Django's leading dot means "this domain and
its subdomains", so .localhost covers every project on the hub. This one
is usually noticed immediately, because nothing works at all.
CSRF_TRUSTED_ORIGINS — without it the site loads and browsing works,
then every form post, login, and admin save returns 403 Forbidden with
"Origin checking failed". This is the one that gets misread as a proxy fault:
GET is unaffected, so routing looks healthy right up to the first POST.
Django matches the browser's Origin header, so list the scheme, and list
both schemes when the project is used over both.
SECURE_PROXY_SSL_HEADER — the hub terminates TLS and forwards plain
HTTP to the container, so request.is_secure() is False on an HTTPS
request until Django is told to trust the forwarded scheme. The hub sets
X-Forwarded-Proto on every request (https over the secure entrypoint,
http over the plain one). Left unset, SESSION_COOKIE_SECURE and
CSRF_COOKIE_SECURE cookies are never stored, SECURE_SSL_REDIRECT loops,
and request.build_absolute_uri() generates http:// links inside an HTTPS
page. Only ever set this where a proxy always overwrites the header, as the
hub does — an application reachable directly can be handed a forged one.
When the hub uses a non-default port, the browser's origin includes it, and a wildcard entry without a port will not match it:
Cookie domain settings often work best when left host-only in local development. OAuth and other external callbacks must use the generated URL expected by the browser.
Other frameworks¶
The same three questions apply under different names: which hostnames the framework will answer to, which origins it trusts for state-changing requests, and how it learns that the original request was HTTPS when the connection it sees is not.
Client addresses¶
The connection an application sees always comes from the hub's side —
Traefik, or the bridge for a host run — so the requester's address travels in
X-Real-Ip and as the first X-Forwarded-For entry:
| Request from | Address the application is given |
|---|---|
| A browser on this machine | 127.0.0.1 |
| A tailnet device | the device's tailnet address, such as 100.101.102.103 |
Another container on the localghost network |
its own address on that network |
Docker delivers the hub's loopback port from an address of its own — the
bridge gateway on a native daemon, the VM's gateway under Docker Desktop — so
the hub renames it: every request passes a small middleware that recognizes
those addresses from Traefik's routing table and records 127.0.0.1 instead.
If that table cannot be read, requests pass through unchanged and a local
request shows Docker's address.
Believe these headers only when the connection itself comes from the hub, a private address. An application that is also reachable directly — a dev server bound to a LAN interface, say — can be handed forged ones.
Tailnet identity¶
With tailnet hosting enabled, a request from a tailnet device also names the person behind it, in the same headers Tailscale Serve sets:
| Header | Value |
|---|---|
Tailscale-User-Login |
the login, such as alice@example.com |
Tailscale-User-Name |
the display name, such as Alice Example |
Tailscale-User-Profile-Pic |
the profile picture URL, when the identity provider has one |
A device that is tagged rather than
owned by a person arrives as login tagged-devices and name Tagged Device,
as it does under Serve. Requests from this machine and from other containers
carry none of these headers, and whatever a client put in them is dropped
before the application sees the request. The identity is the device's
Tailscale login, not a browser session: everyone using that device is that
user. An application can therefore skip its own sign-in for tailnet use, but
still apply the same caution as for client addresses
about connections that bypass the hub.
The hub learns the identity from the gateway, which asks the Tailscale node it runs. The answer is remembered for 30 seconds per address, so a device removed from the tailnet keeps its identity on connections already open for at most that long; Tailscale itself refuses its new connections at once. If the gateway cannot answer, the request arrives without identity rather than failing.
Failure behavior¶
Because localghost is declared external, application startup fails if
the hub's network has never been created. This is intentional: the application
must not silently create a private network with the same name. Start the hub,
then rerun docker compose up for the application.
If the network exists but Traefik is stopped, application containers can start
and communicate on their other networks, but .localhost routes remain
unavailable until the hub starts again.
Unlabelled containers and containers without traefik.enable=true receive no
route even when attached to the shared network.