Published applications

Published applications let users open a private web application at an address of its own, such as crm.apps.example.com, in any browser with nothing installed. On a self-hosted server they are served by the reverse-proxy container, behind Caddy on port 443. This page covers the server side: the applications domain, certificates, adding the reverse proxy to an older server, its options and its logs.

Publishing an application, who may open it and what users see are on Published Applications. On Netzilo Cloud the reverse proxy and the domain netzilo.app are provided and there is nothing to install.

How it works

  1. You choose an applications domain, for example apps.example.com, and point the wildcard record *.apps.example.com at the server.
  2. Caddy serves every name under that domain on port 443 and passes the requests to the reverse-proxy container. No new port is opened.
  3. A user opens https://crm.apps.example.com/. The reverse proxy sends them to the Workplace on your server's domain to sign in, then asks management whether the user may open that application.
  4. Each request then goes through the user's own tunnel, a virtual peer named vp-<n>-RPROXY in the user's account, with that user's policies, routes and DNS.

Only names that are actually published answer. Any other name under the domain answers There is no application at this address.

Before you start

  • An applications domain other than the server's own domain, and not under netzilo.network. A subdomain such as apps.go.example.com or a separate domain such as apps.example.com both work.

  • A wildcard DNS record for it pointing at the server's public IP:

    *.apps.example.com   A   203.0.113.10
    
  • A release with published applications. From any machine, with no token:

    curl -sS -w ' %{http_code}\n' 'https://go.example.com/api/published-apps/exists?domain=check.example.com'
    

    {"published":false} 200 means the server supports them. 404 means management is older: update management, then the dashboard, see Upgrade and add components.

Step 1: Give the domain at install time

When you install a new server, give it the applications domain. The installer then adds the reverse-proxy container and Caddy's site for it.

Installed withWhere to enter the domain
One-line installerThe prompt Published applications domain (optional), or NETZILO_APPS_DOMAIN=apps.example.com for an install without prompts, see Install on your own server
AWS CloudFormationThe parameter Published applications domain
Azure managed applicationPublished applications domain (optional) on the Basics page

The installer warns when the wildcard record does not resolve to the server yet, and continues. It installs without published applications (with a warning) when your own certificate does not cover the wildcard and you gave no second certificate for it.

For a server installed without an applications domain, see Add published applications to an older server.

Step 2: Check the certificates

Certificate modeWhat serves *.apps.example.com
Let's EncryptCaddy gets one certificate per published name, on that name's first visit. Before it asks Let's Encrypt, it checks with the reverse proxy that the name is published, so only real applications get a certificate. The first visit of a new application can take a few seconds, and may fail once while the certificate is issued
Your own certificateUsed as is when it covers *.apps.example.com. Otherwise give a second certificate and key for the wildcard: NETZILO_APPS_CERT_FILE and NETZILO_APPS_KEY_FILE at install time. It is stored in apps-certs/ in the compose folder; renew it like the main certificate
Self-signedThe installer adds *.apps.example.com to the certificate. Browsers trust it only where it is installed

With Let's Encrypt, a name nobody publishes has no certificate, so a browser shows a TLS error for it. That is by design.

Step 3: Switch on published applications

In the dashboard, go to Settings > Permissions and turn on Allow published applications.

The Permissions tab of Settings: Allow clientless access and Allow published applications turned on, with the application domain apps.example.com and the address 203.0.113.10 under Application domains, and a Verify link

FieldWhat to enter
Allow published applicationsOn. A server upgraded to a release with published applications starts with it off
Application domainsClick Add domain, enter the applications domain and the server's public IP. The installer prints the IP and writes it into /opt/netzilo/CREDENTIALS

Click Verify on the row. Management resolves a random name under the domain and reports whether the wildcard record points at the server. It is a check, not a gate: you can save the domain before the record exists.

Then publish applications under Network > Applications, see Published Applications.

Add published applications to an older server

Use this for a server installed before September 2026, or without an applications domain. Never re-run the installer for it: that erases the server. Create the wildcard record first.

Servers installed with the one-line installer. If the published installer has the component commands (check as described in Upgrade and add components), one command adds the reverse proxy, with a backup first:

curl -fsSL https://pkg.netzilo.com/download/install-netzilo.sh | sudo bash -s -- install reverse-proxy

It asks for the applications domain (default apps.<server domain>), refuses the server's own domain and anything under netzilo.network, checks the wildcard record, reuses your certificate when it covers the wildcard or asks for a second one (Let's Encrypt servers need none), checks the new Caddyfile before Caddy loads it, and ends with the dashboard steps. Without prompts, --yes needs NETZILO_APPS_DOMAIN. The same command repairs a broken reverse proxy and keeps its domain and data.

AWS and Azure images, or an installer without the component commands. Add the reverse proxy in place. This applies to Let's Encrypt servers; with your own certificate the wildcard must be in it, see the note after step 4.

  1. In the compose folder (/opt/netzilo, or /opt/netzilo/run on a marketplace image), back up the two files you will edit, and check that the wildcard resolves and the image downloads:

    C=/opt/netzilo            # /opt/netzilo/run on AWS and Azure images
    APPS=apps.example.com
    cd "$C" && sudo cp docker-compose.yml docker-compose.yml.bak-$(date +%F) && sudo cp Caddyfile Caddyfile.bak-$(date +%F)
    grep -q 'reverse-proxy:' docker-compose.yml && echo "already present" || echo "adding"
    dig +short "nz-check.$APPS" A          # must print the server's public IP
    sudo docker pull ghcr.io/netzilo/net-reverse-proxy:latest
    
  2. In docker-compose.yml, add the service before the top-level volumes: line, with --workplace-url set to your server's own address, and add its volume under volumes:. Keep the indentation exactly:

      reverse-proxy:
        image: ghcr.io/netzilo/net-reverse-proxy:latest
        container_name: reverse-proxy
        restart: unless-stopped
        cap_drop:
          - ALL
        security_opt:
          - 'no-new-privileges:true'
        networks:
          - netzilo
        depends_on:
          - management
        volumes:
          - /etc/ssl/certs:/etc/ssl/certs:ro
          - netzilo_reverse_proxy:/var/lib/netzilo-reverse-proxy
        command: [
          "run",
          "--management-url", "http://management:80",
          "--listen", "127.0.0.1:8443",
          "--egress", "off",
          "--publish-listen", ":8444",
          "--publish-tls", "none",
          "--publish-trusted-proxies", "172.20.0.0/24",
          "--workplace-url", "https://go.example.com",
          "--admin-listen", ":9090",
          "--idle-timeout", "60m",
          "--data-dir", "/var/lib/netzilo-reverse-proxy",
          "--log-file", "console",
          "--log-level", "info"
        ]
    
    volumes:
      netzilo_reverse_proxy:
    

    If the gateway service has an extra_hosts: block, copy it onto reverse-proxy too.

  3. In the Caddyfile, add this to the global options block (the first block, in braces, at the top of the file):

      on_demand_tls {
        ask http://reverse-proxy:9090/tls-ask
      }
    

    and this site at the end of the file:

    *.apps.example.com:443 {
        tls {
            on_demand
        }
        reverse_proxy reverse-proxy:8444
    }
    
  4. Check both files, start the reverse proxy and reload Caddy:

    sudo docker compose config -q
    sudo docker run --rm -v "$C/Caddyfile":/etc/caddy/Caddyfile:ro "$(sudo docker inspect caddy --format '{{.Config.Image}}')" \
      caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile | tail -1
    sudo docker compose up -d reverse-proxy && sudo docker compose restart caddy
    sudo docker compose logs --tail=20 reverse-proxy | grep 'publishing on'
    sudo docker exec caddy wget -qS -O /dev/null "http://reverse-proxy:9090/tls-ask?domain=nz-check.$APPS" 2>&1 | head -1
    

    The validation prints Valid configuration, the log shows publishing on [::]:8444, and the last line shows HTTP/1.1 404: Caddy reaches the reverse proxy, which says the test name is not published.

With your own certificate, use tls /data/caddy/apps-certificates/fullchain.pem /data/caddy/apps-certificates/privkey.pem instead of the tls { on_demand } block when the wildcard needs its own certificate. Then this request answers 404 over the wildcard certificate:

curl -sS -o /dev/null -w '%{http_code}\n' -H 'Accept: application/json' "https://nz-check.$APPS/"

On a Let's Encrypt server that request fails its TLS handshake by design. The proof there is the first application you publish: see Test without a browser.

Then switch published applications on as in Step 3.

Roll back. Restore the two .bak- files, then run sudo docker compose up -d --remove-orphans && sudo docker compose restart caddy. A reverse proxy added with install reverse-proxy rolls back from the backup folder the command printed, see Upgrade and add components.

Options

The reverse proxy's settings are command-line options in the command: of the reverse-proxy service in docker-compose.yml. After a change, recreate it with sudo docker compose up -d --no-deps reverse-proxy. A restart sends browsers through the Workplace again, silently while their Workplace session lasts.

OptionDefaultControls
--workplace-urlThe server's addressWhere users sign in. It must be the server's own domain. A wrong value refuses every sign-in with The sign-in did not come from the Workplace
--publish-session-max-age12hHow long a sign-in on one address lasts
--revalidate10mHow often each session's admission is checked again with management
--idle-timeout60mHow long a user's tunnel may go without traffic before it stops
--max-sessions2000Tunnels in total
--publish-trusted-proxies172.20.0.0/24The address range of the proxy in front (Caddy), whose forwarded client addresses are trusted
--log-levelinfodebug logs every connection to a target. Set it back afterwards

Logs and health

cd /opt/netzilo
sudo docker compose logs --tail=200 reverse-proxy
sudo docker compose logs caddy | grep -i 'apps\.'

Every request writes one access line. Its id= is the request id the user sees on a Netzilo error page.

Log lineMeaning
gateway: publishing on [::]:8444 (tls none, sign-in at https://...)Publishing is on; the sign-in address in use
gateway: access GET crm.apps.example.com/path 200 ... user=... id=...One request
gateway: <email> signed in at <address> from <ip> (...)A sign-in was accepted
gateway: <email> refused at <address>: not admitted (...)The user is in none of the application's groups
gateway: <email> refused at <address>: posture: ...The application's posture check failed
gateway: session vp-<n>-RPROXY (<email>) ready: ...The user's tunnel is up
gateway: <email> at <address>: session: ...The tunnel could not start: the user sees Access Failed

The users' tunnels appear in Endpoint > Peers as vp-<n>-RPROXY, one per user, public address and operating system. They keep their names across restarts and are removed automatically some time after they go offline.

Troubleshooting

Test without a browser

From any machine, no token needed, with the address of a published application:

A=crm.apps.example.com
curl -sS -o /dev/null -w 'navigation -> %{http_code} %{redirect_url}\n' -H 'Accept: text/html' -H 'Sec-Fetch-Mode: navigate' "https://$A/"
curl -sS -w '\nfetch -> %{http_code}\n' -H 'Accept: application/json' "https://$A/"

Expect 302 to https://go.example.com/publish-auth?... and 401 with {"error":"sign in required",...}, both over a valid certificate.

The address does not resolve, or the browser shows a certificate error. The wildcard record is missing or points elsewhere (Verify under Settings > Permissions says so). With Let's Encrypt, the first visit may still be getting the certificate: retry, and check sudo docker compose logs caddy. With your own certificate, it does not cover the wildcard.

There is no application at this address. Published applications are switched off, the application is disabled or deleted, or the address differs from the published one.

Sign-in refused: The sign-in did not come from the Workplace. --workplace-url does not match the address users sign in at. Set it to https:// and your server's exact domain, and recreate the container.

Stuck at Logging in. The reverse proxy cannot reach management, or the user's tunnel cannot connect through signal and the relay. Check sudo docker compose logs -f reverse-proxy while the user retries.

Peers show the server's IP for vp-<n>-RPROXY. --publish-trusted-proxies does not cover Caddy's address. Use 172.20.0.0/24, the compose network.

The reverse-proxy container keeps restarting. Read sudo docker compose logs --tail=50 reverse-proxy: a malformed option, or management not reachable for 5 minutes.

For Access Denied and Access Failed, which are about groups, policies and routes, see Published Applications.