Two manifests, one
asd net apply. No port conflicts, no nginx tetris, no edits to anything else.
Level: 3 · Reading time: 10 min
asd net apply with a manifest in git.Already know this rung? Skip to Level 4 — multi-environment where the same project runs side-by-side as dev + staging + prod.
You've got packages/hello/ from Level 2. Now you want a second
service — let's call it api on localhost:4000. Anywhere else
you'd be editing nginx server blocks, juggling ports, and praying
nothing collides. With asd you write one more manifest file and
re-run the same apply.
The two services share Caddy, share the tunnel daemon's connection,
and each gets its own subdomain. Removing a service is symmetric:
delete the file, re-apply, gone.
Add the second manifest:
# packages/api/net.manifest.yaml
id: api
name: API
seedDefaultRegistry: true
endpoint:
url: "http://127.0.0.1:4000"
caddy:
publishPreferred: true
hostRoute:
host: "api-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
tunnel:
public: true
env:
API_URL: "${{ macro.exposedOrigin() }}"
Start an upstream on port 4000 (anything will do — for the demo,
another python3 -m http.server):
# In a third terminal — leave the hello server from Level 2 running
mkdir -p /tmp/api && cd /tmp/api
echo '{"status":"ok"}' > index.html
python3 -m http.server 4000
Apply:
$ asd net apply
ℹ️ Tunnel mode (from ASD_NET_APPLY_TUNNEL): automatic
net:asd → routes:2 services:2
✅ Apply complete: 1 seeded, 2 routes
Note 1 seeded (the new api row added to the registry) but
2 routes (both services). Re-running it converges to 0 seeded, 2 routes.
$ asd urls
🔗 Active workspace URLs:
hello
tunnel: https://hello-xyz1.eu2.tn.example.com
direct: http://127.0.0.1:3000
api
tunnel: https://api-xyz1.eu2.tn.example.com
direct: http://127.0.0.1:4000
$ curl -s https://api-xyz1.eu2.tn.example.com/
{"status":"ok"}
$ curl -s https://hello-xyz1.eu2.tn.example.com/ | head -1
<h1>Hello, world.</h1>
Two URLs, both live, no manual port allocation, no Caddy editing. The
two services see each other under their own subdomain — useful when
the API needs to know the public URL of the frontend (or vice versa).
Both services' env vars (HELLO_URL, API_URL) are now in .env.
To remove the API again:
$ rm -rf packages/api
$ asd net apply
net:asd → routes:1 services:1
✅ Apply complete: 0 seeded, 1 routes (1 stale entry pruned)
Three things to internalise:
Discovery is recursive. asd net apply (and the dry-read
sibling asd net discover) walks every packages/*/net.manifest.yaml
under the project. Adding a service is a file-level operation —
you never tell asd "I added a service", it just notices.
Routes don't conflict because hostnames don't. Each service's
caddy.hostRoute.host resolves to a unique value (different
id, same macro.tunnelClientId(), same macro.tunnelEndpoint()).
Two services on the same machine never compete for a hostname —
they compete for upstream ports (which the OS hands you on bind),
not for the public URL.
The tunnel daemon multiplexes. One SSH connection to the
tunnel server carries traffic for every subdomain that's
tunnel.public: true. You don't pay one process / one
connection per service — asd opens one, registers each
subdomain on it.
To watch this happen in real time, run asd services in another
terminal before asd net apply. The dashboard updates as routes get
added and tunnels come up.
tunnel.public: false on the database manifest, keep the route Caddy-only.reference/net-manifest — full schema.