Routing — ports vs subdomains
Every env exposes one or more services. withvibe supports two ways to reach them: host port mapping (good for solo / local) and per-env subdomains via Traefik (good for shared / cloud deployments).
Quick comparison
| Port mode | Subdomain mode (Traefik) | |
|---|---|---|
| URL shape | http://localhost:51234 | https://web.env-a3f1c2.example.dev |
| DNS required | No | Wildcard *.<baseDomain> → host |
| TLS | Plain HTTP | Traefik + ACME wildcard cert |
| Concurrent envs | Bounded by free ports / collisions | Effectively unlimited |
| Best for | Local dev box, single user, exporting envs | Cloud, shared team, customer demos |
Direct host port → container
The orchestrator allocates a free port per env. The browser connects directly to the host on that port; Docker does the rest.
- · Each env gets a unique host port from the allocator.
- · No DNS, no certificates. Plain HTTP on a port.
- · Two envs publishing the same internal port = different host ports — no collision.
Traefik routes by hostname
DNS resolves *.example.dev to your host. Traefik terminates TLS, reads the Host header, and forwards to the right container over a Docker network — never via published host ports.
- · No host ports exposed — services live on the
proxyDocker network. - · Every env gets its own subdomain prefix derived from its id.
- · TLS via ACME (Let's Encrypt) with a wildcard cert.
Port mode
The default. The orchestrator's port allocator picks free host ports for every service marked with ports: or a system-port variable. It records the allocation on the env so the same service stays on the same port across restarts.
Set PUBLIC_HOST in the API process env (defaults to localhost). It can be a hostname or an IP — anything teammates on the same network can reach.
# api.env
PUBLIC_HOST=localhost # or 192.168.1.50, dev-box.lan, etc.Service URLs in templates get rendered as http://$${PUBLIC_HOST}:<port>. Local exports always force PUBLIC_HOST=localhost regardless of the server setting.
Subdomain mode
Each env gets a short identifier (last 6 characters of its CUID, lowercase). Services are published as <service>.env-<short>.<baseDomain> via Traefik running in front of the Docker daemon.
Configure these on the workspace:
routingMode:"subdomain"routingBaseDomain: the base domain you control DNS for, e.g.example.dev
*.<baseDomain> → <your host>) and, if you want HTTPS, a wildcard certificate. Without them, browsers can't resolve env URLs.How the rewrite works
When subdomain mode is active, the compose rewriter does the following per service:
- Detects whether the service is “exposed”: it has
ports:,x-expose: true, or pre-existingtraefik.enable=truelabels. Explicitx-expose: falsealways wins. - Strips the
ports:stanza — Traefik talks to the service over the internal Docker network, not via published ports. - Attaches the service to two networks: the external
proxynetwork (where Traefik lives) and a per-envinternalnetwork. - Adds Traefik labels:
traefik.enable=true,traefik.http.routers.<id>.rule=Host(`...`), andtraefik.http.services.<id>.loadbalancer.server.port=<internal port>. - Records the resulting
service → URLmap so the UI can link to it.
The internal port is chosen in this order: an existing traefik...server.port label (template author's intent) → the first ports: entry → an expose: entry → an x-port hint → 80 as last resort.
From your compose file to a Traefik-aware one
services:
web:
image: my/web:latest
ports:
- "${WEB_PORT}:3000"services:
web:
image: my/web:latest
networks: [proxy, internal]
labels:
traefik.enable: "true"
traefik.http.routers.env-a3f1c2-web.rule: "Host(`web.env-a3f1c2.example.dev`)"
traefik.http.services.env-a3f1c2-web.loadbalancer.server.port: "3000"The ports: stanza is dropped, two networks are attached, and Traefik labels are injected. Internal port resolution priority: existing label → ports → expose → x-port → 80.
Reference Traefik service
Run a single Traefik instance per host that joins the external proxy network. Minimal config:
# traefik.yml
services:
traefik:
image: traefik:v3
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=proxy
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.dnschallenge=true
- --certificatesresolvers.le.acme.dnschallenge.provider=cloudflare
- [email protected]
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- proxy
networks:
proxy:
external: trueCreate the external network once, before bringing up either Traefik or any env:
docker network create proxyChoosing a mode
- Run port mode ifyou're evaluating withvibe, running it solo on a laptop, or your team uses local exports as the primary workflow.
- Run subdomain mode if envs are shared with non-technical teammates, customers, or sales prospects. URLs that look like
https://demo.env-a3f1c2.acme.devare dramatically friendlier thanhttp://10.0.4.12:51234, and you avoid port collisions altogether.
Switching modes
Routing mode is a workspace-level setting. Existing envs keep their current routing on restart — they don't silently re-materialize. Recreate the env (or run Recompose) to pick up the new mode.