Multi-port apps
Most apps serve one port and none of this matters. This page is for the ones that serve two: HTTP alongside gRPC, an API alongside a metrics or admin endpoint.
#Three things that all mention numbers
The single biggest source of confusion here is that three unrelated mechanisms all talk about ports. They do not interact:
| what it is | what it does | |
|---|---|---|
[ports] |
a declaration in ply.toml |
tells ply what the app binds. ply never opens it |
[env] PORT |
an environment variable | what your app reads to decide where to listen |
--publish |
a host claim | binds a host port and splices it into the app |
[ports] web = 3000 does not set PORT, and it does not make anything
reachable. It is metadata: ply uses it to decide whether to keep
CAP_NET_BIND_SERVICE (any declared port below 1024), to refuse a scale that
would collide, to tell dependants where you live, and to render ply ps.
Your app listens because your app read PORT — or because it has 3000
compiled in.
#Declaring two ports
[package]
name = "myapp"
version = "0.1.0"
entrypoint = ["./server"]
include = ["server"]
base = "debian@13"
[ports]
api = 8443
grpc = 50051
[env]
PORT = "8443" # whatever your app actually reads
GRPC_PORT = "50051"
[health]
port = 8443 # the port that means "really serving"
grace = "10s"
[restart]
policy = "on-failure"
Pick the health port deliberately: it should be the one that goes bad when the app goes bad. A metrics endpoint that answers while the API is wedged will hold a broken deploy open.
#Publishing both
ply run myapp.img --publish 8443:8443 --publish 50051:50051
Always use the HOST:INSTANCE form when you publish more than one port.
Not --publish 8443. The reason is that PORT is a single variable, so at
most one port can be communicated to the app that way — the rest are expected
to bind the port you declared. Naming both sides tells ply you have already
settled where the app listens, and it stops trying to help.
The publish grammar is PORT, HOST:INSTANCE, or ADDR:PORT[:INSTANCE],
where ADDR is internal, public, or an IPv4 address:
--publish 8443:8443 # public, host 8443 → instance 8443
--publish internal:9090:9000 # other ply apps on this host only
--publish 127.0.0.1:8080:3000 # an explicit address
internal is the right scope for anything that is not meant for the
internet — metrics, admin, an internal gRPC API. It keeps the port off
0.0.0.0 while leaving it reachable by other apps on the host.
#Publish order is load-bearing
The first --publish is the app's canonical address. It is what
--after dependants receive as <APP>_ADDR / <APP>_HOST / <APP>_PORT,
and it is the single backend ply proxy emits.
ply run myapp.img --publish 8443:8443 --publish 50051:50051
# ^^^^^^^^^^^^^^^^^^^ this one is "the" address
Put the port you consider primary first. Reordering the flags changes what dependants are told and what the generated proxy config points at.
#`ply proxy` emits one vhost per app
ply proxy generates config for a real reverse proxy (Caddy, nginx,
haproxy) that maps one hostname to one service:
myapp.example.com {
reverse_proxy 127.0.0.1:8443
}
A second port is not a second backend for that hostname — it is a different
service, and it needs its own hostname or path. ply proxy does not model
that, and deliberately so: which hostname routes to which port is the one
irreducible human decision. For multi-port apps, write the edge config by
hand (see below).
#L4 vs L7: the gRPC problem
--publish is a layer-4 TCP splice. It copies bytes in both directions
with TCP_NODELAY set and no timeouts, and it understands nothing about
what flows through it. That is exactly what you want for gRPC streams — but
it has one consequence worth understanding before you scale.
Backend selection happens once per TCP connection, at accept time. The connection then stays on that backend for its whole life. For HTTP/1.1 that balances well, because browsers open many short connections. For gRPC it does not balance at all: a gRPC client opens one long-lived HTTP/2 connection and multiplexes every RPC over it, so one client is pinned to one instance until it reconnects.
ply run myapp.img --publish 50051:50051 --scale 4 # 4 instances, no gRPC balancing
This is not specific to ply — it is what L4 means, and it is the standard reason gRPC wants an L7 proxy. If your gRPC traffic needs to spread across a pool, terminate it somewhere that balances per request.
#The edge, for real protocol routing
Put Caddy in front and keep the app's own ports internal:
ply run myapp.img --publish internal:8443:8443 --publish internal:50051:50051 --scale 4
Then, in the edge's config volume:
api.example.com {
reverse_proxy {
dynamic a myapp.ply 8443
}
}
grpc.example.com {
reverse_proxy {
dynamic a myapp.ply 50051
transport http {
versions h2c 2
}
}
}
Two things are doing real work here:
transport http { versions h2c 2 } is required for gRPC. Without it
Caddy speaks HTTP/1.1 to the backend and gRPC fails outright. This is the
most common mistake when putting gRPC behind Caddy.
dynamic a re-resolves <app>.ply on every request, and ply keeps those
host entries in step with the pool. That gives you per-request balancing —
which is precisely what the L4 splice cannot do for gRPC — and it means
scale, deploys and crash-respawns need no config edits and no reloads.
The edge keeps its Caddyfile in a volume, not in the image, so you edit
it in place and --watch hot-reloads. See demos/edge/ for the full
pattern.
#Scaling a multi-port app
--scale 1 |
--scale N |
|
|---|---|---|
| rootful | fine | fine — every instance gets its own bridge IP, so all can bind both ports |
| rootless | fine | not supported for two ports |
All instances of one rootless ply run share that run's single network
namespace, so a second port collides on the second instance. ply warns when
it sees this. If you need to scale a multi-port app, run it rootful.
#Deploys cut open streams
A rolling deploy replaces instances; the splice has no drain. When an instance goes away, an open connection through it simply ends and the client sees EOF. Request/response traffic re-dials and never notices. Long-lived server-streaming RPCs will break on every deploy — make sure your clients reconnect (most gRPC libraries do by default).
#See also
- Running & scaling — instances, IPs,
--scale - ply.toml reference — every manifest key
- Deploys, health & restarts — health gates and rolling deploys