Publishing services

A service on a member, such as a NAS at home, can be reached from the internet by name. A device with a public address passes the connections on; the encryption ends at the member.

Linux · FreeBSD

What it is #

You run a service at home, say a document server on your NAS, and want to open it from any browser as docs.example.org. Your router at home opens no port for this, and its address is in no public record.

A few words first:

  • A domain is a name you own on the internet, such as example.org.
  • A DNS record is an entry at your DNS provider that says which address a name points to.
  • TLS is the encryption a browser uses for https:// addresses.
  • A certificate proves to the browser that the server really is docs.example.org. juist gets one from Let’s Encrypt.
  • An ingress is a device of your network with a public address, a small VPS for example, that an admin gave the role ingress. It takes connections on port 443, reads the name the browser asks for, and passes the connection through the tunnel to the member that publishes the name.
  1. browseranywhere

    Asks for docs.example.org.

  2. TLSvpsingress

    Reads the name in the TLS ClientHello and passes the connection on.

  3. WireGuardnastarget

    Ends TLS, with a certificate from Let's Encrypt.

  4. HTTP127.0.0.1:8080service

    Gets the plain connection.

The ingress sees the name, the client's address, sizes and timing, not the content.

The ingress ends no TLS, so it never sees the content. TLS ends at the NAS, and the NAS passes the plain connection on to the service. One ingress serves every name of the network.

Setting it up #

1on an admin's device

Set the network’s domain and give the VPS the role:

juist network domain example.org
juist grant vps ingress
2on your DNS provider's website

Point *.example.org at the VPS’s public address, for every name at once, or docs.example.org alone.

3on vps, the ingress
juist ingress serve

It asks once for sudo, starts the juist-ingress service, and opens ports 443 (TCP and UDP, for HTTP/3) and 80 in firewalld or ufw, and the ports names are published on by TCP as they come and go (below); on FreeBSD, pf is left to the host’s own rules. Port 80 only redirects to https.

4on nas, the target

Its operator, the local user who manages the device, publishes the name and says where the plain connection goes:

juist publish add docs 127.0.0.1:8080

docs goes to port 443 on the NAS. juist gets a certificate through the ingress and ends TLS here. It prints nas: publishes docs.example.org:443, serving docs.example.org and ending TLS for docs.example.org to 127.0.0.1:8080, then the CAA record that keeps other accounts from certificates for the name (see below), and the first time warning: a server published from this device reaches the network and its LAN once compromised.

Publishing is an admin’s change. Where the NAS holds no admin key, it prints docs.example.org goes to 127.0.0.1:8080 once published on this device and the command an admin runs, juist publish add nas docs.example.org. Where a change needs more than one admin, it waits for the others (how changes are approved).

juist grant and juist publish add name the next step and the device it runs on. Run there by that device’s operator, they take that step too. juist invite vps --ingress admits a new device as the ingress at once.

Keeping the server away from your network #

Danger

A server published from a full member reaches the network and the member’s LAN once someone breaks into it. Publish from one only what you would put on the internet anyway.

A service member keeps the server away from them: a juistd of its own beside the service, with no privilege, which every device keeps from opening any connection to it. Only its replies pass.

5on an admin's device
juist invite immich --service immich

It admits the new device, named immich, as a service member that publishes immich.example.org, and prints the link to join with.

6on the server, the service member

In Docker, the service goes on an internal network with the service member alone, and the service member’s image (make image in the source tree, then docker load) on that network and one with a way out:

docker network create --internal -o com.docker.network.bridge.gateway_mode_ipv4=isolated immich-only
docker network connect immich-only immich-server     # and disconnect it from every other network
docker run -d --name immich-juist -v immich-juist:/var/lib/juist -e JUIST_JOIN='juist:…' \
    juist:VERSION --serve immich=immich-server:2283
docker network connect immich-only immich-juist

It joins by the link once started, and ignores it once in the network; docker logs immich-juist says how the join went, with the words to compare where the invite asks for them.

The same as one Compose file, with a web service to try it on, for Docker and Podman: examples/service-member in the repository.

Outside Docker, juistd --serve immich=127.0.0.1:2283 runs it as any user. --serve @=web:80 serves the domain itself.

It ends TLS itself, with a certificate from Let’s Encrypt, and answers nothing but what it publishes and log sync. The service has no way out, so one that downloads things at its first start needs that done beforehand. The isolated gateway mode, Docker 29’s, keeps the host from the service too; without it, the service reaches whatever on the host listens on every address, at the network’s gateway.

HTTP/3 #

Browsers reach a name over HTTP/3, QUIC on 443/udp, which takes one round trip less to connect and loses no time to a lost packet on a slow link, where its service speaks HTTP and juist ends its TLS:

juist publish add docs 127.0.0.1:8080 --http3

It prints ending TLS for docs.example.org to 127.0.0.1:8080, with HTTP/3, and a DNS record to add at your DNS provider, docs.example.org. HTTPS 1 . alpn="h3,h2", which has browsers try HTTP/3 from their first request; without it they do from their second, told by the Alt-Svc header juist adds to its answers. Where 443/udp is blocked anywhere on the way, browsers keep to HTTP/2 over TCP.

  • juist then proxies the service’s HTTP over TCP too, offering clients HTTP/2: the service gets every request with its own Host, as before, and no X-Forwarded-For or other header of the proxy’s. With --http2 besides, juist asks the service by h2c.
  • The status page is offered HTTP/3 as it is. A service member offers it for its names with juistd --serve NAME=ADDR --http3.
  • WebSockets keep to HTTP/1.1 over TCP; juist passes them on as they are.
  • --proxy names the client to the service on each of its connections, as over TCP.
  • A client whose address changes, as a phone moving from Wi-Fi to LTE, connects anew; shares stay on TCP.
  • A client connecting from one UDP socket to names on two devices, as Go’s HTTP/3 client does, reaches the first device alone over QUIC.

Names for members alone #

A name can hold a certificate from the CA, as any other, and still be reached by members of the network alone, such as the family’s photos at photos.example.org:

7on an admin's device
juist publish add nas photos --members

It prints nas: publishes photos.example.org:443 (members), and juist publish lists it with (members).

8on nas, the target
juist publish add photos 127.0.0.1:2342

juist gets the certificate through the ingress and ends TLS here, as for any name. With --members here too, the service never goes to the name while the log has it public, which juist status warns of, and juist publish marks it (members); later commands keep that until --members=false. A service member marks its services so with juistd --serve NAME=ADDR --members.

Without an access policy, every member may open it. With one, the members a rule names it for: pass from <family> to name photos.

  • The ingress passes the name to nobody but the CA, which checks the name by TLS on 443 when it issues and renews the certificate; anyone else gets nothing, and the ingress logs members only. Its public DNS record points at the ingress all the same, for the CA.
  • The device ending TLS takes the name only from the members it is for, by the address in the network their connection comes from, and from the ingress only the CA’s check: an ingress someone took over gets no further.
  • Members reach it at its device, as any name on 443, on Linux with systemd-resolved. A device that resolves the network’s names publicly reaches it not at all: FreeBSD and Linux without systemd-resolved, where juist status warns of each such name, and the Android app.
  • Its service is its own: juist does not serve a name for members alone whose service another name’s bytes, or a share’s, also reach as they are, where a client of the other could ask for it by Host, and juist status says so. A name juist proxies HTTP for (--http3) is held to its own Host. Let the service listen on 127.0.0.1 alone, and pass no other device’s name on to it.
  • The device itself does not reach the name through juist, so that none of its programs, one a client of a public service there can make ask, takes it: reach the service there at its own address.
  • Update the device ending its TLS, and every ingress, before the network takes names for members alone (juist log upgrade): an older device stops at the upgrade and serves its names as they were until its view goes stale.
  • A name for members alone is on 443, by TLS, without --proxy. juist publish add nas photos keeps it so; --members=false makes it public.
  • Its name is in the CA’s public certificate logs, as every certificate’s is: what it is called is not secret, only what it serves.

A game server or another TCP service #

A service that speaks no TLS, such as a Minecraft server, gets a port of its own on every ingress, which passes each connection on that port to the device at once, reading nothing of it.

9on an admin's device
juist publish add nas mc 25565 --tcp

It publishes port 25565 of nas on port 25565 of every ingress, as mc.example.org, and prints nas: publishes mc.example.org:25565/tcp. --tcp=PORT takes another public port: juist publish add nas ssh 22 --tcp=2222 prints ssh.example.org:2222/tcp to 22.

10on nas, the target

Where the service listens on every address, its operator agrees with juist publish serve. Where it listens on loopback alone, juist passes the bytes on to it as they are:

juist publish add mc 127.0.0.1:25565 --tcp

It prints passing mc.example.org to 127.0.0.1:25565.

Players then connect to mc.example.org:25565. Every ingress holds each such port while a name is published on it, answering nothing where the device does not serve it, and on Linux lets it through firewalld or ufw while the ingress runs, closing it again once the name is withdrawn or the ingress stops; juist status there names the ports, and those another program holds.

  • Only admins publish a TCP port, as a change of the network; the role publish shares on 443 alone.
  • A public port is one name’s in the network. 80, 443 and juist’s own ports, 41640 to 41739, are refused, and so is one an ingress’s own name goes to.
  • Members reach the name at its device directly where its public port is the device’s port and it has no --proxy, and through the ingress otherwise.
  • --proxy sends the client’s address first, in a PROXY protocol v2 header, which the service must expect: Paper and Velocity do where told to, vanilla Minecraft does not.
  • juist prints no SRV record. A client that looks one up, as Minecraft’s does for a name without a port, finds one you add at your DNS provider: _minecraft._tcp.mc.example.org. SRV 0 5 25565 mc.example.org.
  • UDP is not passed on, so neither is Minecraft Bedrock’s 19132/udp.
Caution

The ingress sees everything a protocol that does not encrypt itself carries, and a CAA record protects nothing there. Publish that way only what encrypts itself, as SSH does, or what you would show anyone.

Sharing at once #

A device an admin gave the role publish (juist grant laptop publish) publishes under its own name, laptop.example.org, without asking an admin each time:

juist share ~/photos/2026          # at https://laptop.example.org/RANDOM/, until Ctrl-C
juist publish 8000 --for 2h        # this device's web server on port 8000, for two hours
juist share -b --for 7d ~/talk.pdf # in the background, for a week
juist share                        # lists this device's shares, with their links
juist share --stop ~/talk.pdf      # ends one, by its file, port or link, whichever command holds it

A share is read under its random path alone; anywhere else on the name answers 404, unless juist publish PORT takes it, and entries such as .git stay hidden. A directory is listed, sortable by name, size or date, and downloads whole, or any folder in it, as a ZIP; a folder holding an index.html shows that page instead. It ends with the command or at --for, seven days at most, and where the device goes away, ten minutes later at most. With --background (-b), the command returns once the share is up, and the share goes on, under the same link even across a restart of juistd, until juist share --stop or --for; juist share lists it with its link, and juist status counts it under Sharing. One certificate for the device’s name covers every share, so their paths reach no certificate log. A device holds up to 16 shares at once, each under a path of its own, and one juist publish PORT beside them, which answers what no share’s path does. juist share --stop without a name ends the only share; where several run, it says to name one. Renamed, it shares under its new name, and juist share prints the new link.

A status page #

A device serves the network’s status page under a published name, in this website’s look, light or dark as the browser asks:

juist publish status-page status                # at https://status.example.org/
juist publish status-page status --public all   # anyone sees all of it

Members see all of it: every device with its addresses and roles, how the serving device reaches it (direct, through a relay, sought or absent) and when it last heard from it, the published names and shares and whether each is up, what needs attention, and the log’s newest changes. Anyone else sees what --public says:

--publicAnyone but a member sees
nonenothing: the page answers 404
services, the defaultthe published names and shares, and whether each is up
networkall but the changes
allall of it

The page knows a member by the overlay address its connection comes from; whoever comes through the ingress is the public, whatever the ingress says. It refreshes itself every 15 seconds while it is open, which makes it a status screen for a phone, too. Opened once with https://, the browser keeps to https for it.

Devices, published names and changes are the network’s, the same on every member. Reach and what needs attention are the serving device’s own, and the page says whose: serve it from a device that is always on and reaches the others well. An ingress cannot, holding port 443 itself.

Caution

With --public network or all, anyone who has the address sees your devices’ names, overlay addresses and roles, and with all who approved which change. Only members see that otherwise.

juist publish status-page status --public none says anew what the public sees, and juist publish remove status takes the page down. It is a name whose TLS juist ends, as with juist publish add NAME HOST:PORT; a service member serves it with --serve status=status-page:all, and its reach is then that of a device every other keeps from opening connections.

Good to know #

  • A published name is one label under the domain, docs.example.org, or the domain itself, example.org, spelled out: juist publish add nas example.org. A device publishes 16 names at most. One device at most publishes the domain itself, and it needs A and AAAA records of its own at your DNS provider, the ingress’s, which *.example.org does not cover. A CAA record there holds for every name under it that has none of its own: give each device’s names their own.
  • A name with a record of its own, as the CAA or HTTPS record juist prints, gets no address from *.example.org any more: give it A and AAAA records of its own too, the ingress’s.
  • A service can end TLS itself and hold its own certificate, as it would facing the internet. An admin then publishes it with juist publish add nas docs, on port 443 unless you give another port after the name, and nas’s operator agrees with juist publish serve.
  • --proxy on juist publish add hands the service the client’s address in a PROXY protocol v2 header. Members then reach the name through the ingress too.
  • An ingress passes 16384 connections at once, 256 from one address and 1024 from one /24 or /48; juist ending TLS on a device takes 8192. --max-conns N and, for the ingress, --max-per-source N change that: on Linux in a drop-in for juist-ingress or juist-serve, on FreeBSD in juist_ingress_args or juist_serve_args.
  • --http2 on juist publish add NAME HOST:PORT offers clients HTTP/2, for a service that takes it without TLS (h2c) besides HTTP/1.1. A browser then opens one connection instead of up to six. A service that takes HTTP/1.1 alone fails with it.
  • Every member on Linux with systemd-resolved sends the whole domain to juistd, which answers a name published on 443, or shared, with its device’s address while that device is reachable, so that members reach it directly through the tunnel, with the same certificate, and passes every other name of the domain on as before, held for 30 seconds at most, so that a name newly published is reached directly soon. DNSSEC is off for the domain on members (Names). http:// to such a name goes to the device’s port 80, where juist redirects it to https, unless another program listens there on every address: that program then answers, which juist status warns of.
  • juist publish lists the published names, and where this device sends its own. juist publish remove docs stops publishing one on this device, juist publish remove nas docs on another. juist publish serve --stop stops serving and keeps where names go. juist ingress serve --stop stops the ingress.
  • An ingress and juist’s own TLS run on Linux and FreeBSD, for the device’s default network. On FreeBSD, pf, where the host runs it, lets 443 by TCP and UDP, 80 and the ports names are published on by TCP in by the host’s own rules.
  • juist network domain off is refused while a device publishes names under the domain, with the juist publish remove that comes first.
  • An ingress is never a voucher or an exit node, publishes nothing under its own name or on 80, 443 or a port a name is published on by TCP, which it holds itself, and routes no subnet; a service member holds no other role. juist grant names the role that is in the way. Every member takes from it only connections to the ports it publishes and serves, and log sync, replies to what it opened itself, pings included, and ICMP errors on its own connections. Everything else from it is dropped.
  • A client that does not answer its service’s TLS hello within a minute is let go, and any connection quiet both ways after 5 minutes.
  • A client gets 10 seconds to send its TLS hello to the ingress.
  • The ingress logs a line for each connection: the client’s address and port, the name it asked for, what came of it (spliced, not served, no hello, target unreachable, refused), the bytes each way and how long it lasted; on port 80 the method, path, status and User-Agent too, and on a port published by TCP the port and the name it goes to; a QUIC flow is logged once it ends, as access: quic, not proven where its client never completed a handshake, which a spoofed address cannot. Lines of such flows, and of QUIC refused, come once a second at most, saying how many more went unsaid. Past port 80 it sees neither path nor User-Agent, TLS ending at the device. journalctl -u juist-ingress shows them, on FreeBSD /var/log/juist-ingress.log, kept as long as the journal or newsyslog keeps them.
  • The ingress runs as its own user, juist-ingress, holds no key of the network, and may ask juistd one thing: what is published.
  • An ingress whose view of the network is stale serves nothing. If its juistd stops answering, the names go down within fifteen seconds.
  • juist revoke vps ingress, or service, makes the device a member like any other again, which juist warns of: warning: vps is confined no more, now a member like any other. One that may be compromised goes by juist remove. Without the ingress role, run juist ingress serve --stop on it.
Caution

Whoever controls the ingress can get a certificate for your names and intercept public clients, and members that reach a name through it: those on another port than 443, behind --proxy, or without systemd-resolved. Members that resolve a name on 443 to its device connect to it directly. A CAA record, a DNS record that says who may issue certificates for a name, bound to the target’s ACME account closes that gap: juist publish add NAME HOST:PORT prints it, and the terminator’s log names it for a service member.

If something goes wrong #

You seeWhat to do
the network has no domain to publish underjuist network domain example.org first
on nas, juist status: Published says not served here: not agreed to on this device (juist publish serve)run juist publish serve on nas
on vps, juist status: Ingress says not running on this device (juist ingress serve)run juist ingress serve on vps
juist ingress on vps: nothing to serveno name is published and served yet; a hint says when the view is stale
the ingress does not answer on 443: …journalctl -u juist-ingress shows why; on FreeBSD, /var/log/juist-ingress.log
the terminator does not answer on …journalctl -u juist-serve shows why; on FreeBSD, /var/log/juist-serve.log
this device holds no publish rolean admin runs juist grant DEVICE publish
juist status: Sharing says reached by nobody: the terminator does not runjuist share starts it; journalctl -u juist-serve shows why it stopped
juist status: Published says …, but ends TLS for none: the terminator does not run (juist publish serve)run juist publish serve; journalctl -u juist-serve shows why it stopped
the terminator does not say it listens on …it did not start in time; journalctl -u juist-serve shows why
juist status: warning: another program holds port 80 on …members opening http://NAME reach that program instead of a redirect to https; have it listen on the addresses it serves alone, and open the name with https:// meanwhile
juist status: warning: no certificate for NAME: …the CA issued none, for the reason given; most often it cannot reach NAME, whose public DNS must point at a running ingress. Until then a client’s TLS fails
juist status: warning: not serving NAME, which is for members alone: OTHER reaches its service as it is, …OTHER is passed by TCP, or not proxied, to the same service, whose clients could ask it for NAME; give NAME a service of its own
juist status: warning: not serving NAME, its service meant for members alone: the log has the name publican admin publishes it for members: juist publish add DEVICE NAME --members; or, if it is to be public, juist publish add NAME HOST:PORT --members=false on the device
juist status: warning: NAME, for members alone, is not reached from here, …this device resolves the network’s names publicly, so NAME leads to the ingress, which refuses it; on Linux, use systemd-resolved
another program holds port 443 on …, where juist would end TLSstop that program, or publish on another port
on vps, juist status: Ingress says …; another program holds port 443/udpanother program, often a web server’s own HTTP/3, holds 443/udp on the ingress; browsers keep to TCP until it lets go, and the ingress takes the port then
access: quic … refused: another name's flow from its addressa client connected to names on two devices from one UDP socket, which the ingress passes to one of them alone; browsers use a socket for each
HTTP/3 slower than HTTP/2 under loadthe host’s UDP buffers are small: sysctl -w net.core.rmem_max=7500000 net.core.wmem_max=7500000 on the device ending TLS
another program holds port 25565 on …, where juist would pass NAME by TCPthe service listens on every address, the device’s overlay addresses too: publish its port instead, as the hint says, and agree with juist publish serve
on vps, juist status: Ingress says …; another program holds port 22a program on the ingress, often its sshd, holds a port a name is published on by TCP, which juist then leaves to that program and to the firewall’s rules: publish the name on another port with --tcp=PORT
this device publishes a port alreadyanother juist publish PORT runs, perhaps in the background: juist share lists it, juist share --stop PORT ends it
this device holds 16 shares alreadyjuist share lists them; end one with juist share --stop
this device is no ingressan admin runs juist grant DEVICE ingress
warning: the network has no ingress, so nothing reaches it from the internetgrant a device with a public address the role ingress
warning: nothing answers on 127.0.0.1:8000 yetstart the web server that juist publish 8000 points at
warning: firewalld blocks tcp/443 on juist0, …juist0 is not in firewalld’s trusted zone, which the package sets at its first install alone; run the firewall-cmd the hint gives