Skip to content

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:

uvx localghost hub up

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:

COMPOSE_PROJECT_NAME=my-project

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:

uvx localghost trust install
uvx localghost trust status

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:

CSRF_TRUSTED_ORIGINS = ["http://*.localhost:8080"]

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.