Networking Azure

Expose on Ubuntu 24.04 on Azure User Guide

| Product: Expose on Ubuntu 24.04 LTS on Azure

Overview

Expose is an open source reverse tunnel server written in pure PHP, and the self hosted alternative to hosted tunnelling services such as ngrok. A small Expose client running on a developer's machine opens a persistent outbound connection to this server. The server then accepts inbound public HTTP requests and routes them back down that connection to whatever is running on the developer's laptop, so a work in progress site, an API under development or a webhook receiver becomes reachable at a real public URL without deploying anything and without opening a firewall port.

Running the tunnel server yourself keeps development traffic, webhook payloads and pre release demos on infrastructure you control and under your own domain, and removes the per seat subscriptions, session time limits and rotating subdomains that hosted tunnelling services impose.

The cloudimg image installs the official Expose server at /opt/expose/app, running as the expose-server service bound to loopback 127.0.0.1:8080, behind nginx which terminates TLS on port 443. Neither the dashboard password nor the tunnel token ever crosses the wire in plaintext.

Secure by default, no known credential and no open relay. Upstream Expose publishes a default dashboard login in its own configuration file, and defaults to accepting a tunnel from anyone who can reach the server. This image does neither. It contains no administrator credential at all: an expose-firstboot.service oneshot generates a unique dashboard password and a unique tunnel authentication token on each VM's first boot, writes them to a root only file, regenerates a per VM TLS certificate and renders the nginx site, then hands over to the server. Token validation is switched on, so only a client holding your token can open a tunnel. A start time guard re-checks this on every service start and refuses to run if a published default credential is ever in effect, so the appliance fails closed rather than serving a known login.

What is included:

  • Expose server (verified at 3.2.0) from the official upstream release
  • The built in Expose admin dashboard, served over TLS
  • expose-server.service bound to loopback 127.0.0.1:8080
  • nginx terminating TLS on 443, with 80 redirecting to HTTPS and serving an unauthenticated /healthz probe
  • expose-firstboot.service for the first boot credentials, per VM TLS certificate and nginx site
  • expose-token-seed.service, which registers your per VM tunnel token with the server
  • A unique per VM dashboard password and tunnel token, in a root only 0600 file
  • Token validation enabled, so the server is never an open relay
  • Ubuntu 24.04 LTS base, fully patched
  • 24/7 cloudimg support, 24h response SLA

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet with a subnet. Expose is light on resources because it proxies traffic rather than running your application: Standard_B2s (2 vCPU, 4 GB RAM) is sufficient for most teams.

To publish tunnels under your own hostnames you also need a domain you control and the ability to add DNS records for it. This is covered in Step 6. The server boots and the dashboard works without any DNS at all, so you can deploy first and add your domain later.

Step 1: Deploy from the Azure Portal

Search the Marketplace for Expose on Ubuntu 24.04, choose your VM size, and attach an NSG that allows TCP 22 (SSH) from your management network and TCP 443 (dashboard and tunnel traffic) from the internet. Tunnel traffic must reach the VM from wherever your public URLs are consumed, so 443 generally needs to be open broadly. Port 80 only issues an HTTPS redirect and serves the health probe.

Step 2: Deploy from the Azure CLI

Run these on your own workstation, where the Azure CLI is installed:

az vm create \
  --resource-group my-resource-group \
  --name my-expose-vm \
  --image cloudimg:expose-ubuntu-24-04:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

# Open the ports the appliance uses
az vm open-port --resource-group my-resource-group --name my-expose-vm --port 443 --priority 1001
az vm open-port --resource-group my-resource-group --name my-expose-vm --port 80  --priority 1002

Step 3: Connect via SSH

ssh azureuser@<vm-ip>

Step 4: Verify the services are running

First boot generates your credentials and renders the nginx site before the tunnel server starts, so give the VM a few seconds after it first boots.

systemctl is-active expose-server nginx expose-firstboot

Expected output:

active
active
active

A fuller view of the tunnel server:

systemctl status expose-server --no-pager | head -12

Expose appliance services running after first boot

The health probe answers without credentials, which is what a load balancer should watch:

curl -s http://127.0.0.1/healthz

Expected output:

ok

Step 5: Read your per VM credentials

Every VM generates its own dashboard password and tunnel token on first boot. They are written to a root only file:

sudo cat /root/expose-info.txt

Per VM credentials generated at first boot

EXPOSE_ADMIN_PASSWORD signs you in to the dashboard. EXPOSE_AUTH_TOKEN is what an Expose client presents to open a tunnel. Treat both as secrets. No two VMs share them and neither is baked into the image.

Confirm the tunnel server is answering through the TLS front door:

curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/health

Expected output:

200

Step 6: Point your own domain at the server

Expose routes purely on the hostname of the incoming request: a tunnel published as demo is served at demo.your-domain.com. To use your own domain, add two DNS records pointing at the VM's public IP:

your-domain.com        A    <your-vm-ip>
*.your-domain.com      A    <your-vm-ip>

The wildcard record is what lets every new tunnel get its own hostname without adding DNS each time. Then set the domain on the VM and re-render the certificate and nginx site:

sudo sed -i 's|^EXPOSE_DOMAIN=.*|EXPOSE_DOMAIN="your-domain.com"|' /etc/expose/expose.env
sudo /opt/expose/render-tls.sh
sudo /opt/expose/render-nginx.sh
sudo systemctl reload nginx
sudo systemctl restart expose-server

Until you do this, the tunnel domain defaults to the VM's own address, which is enough to reach the dashboard but not to serve tunnel subdomains. /etc/expose/README.txt on the VM repeats these steps.

Step 7: Sign in to the dashboard

Browse to https://<your-vm-ip>/ and sign in with the user admin and the EXPOSE_ADMIN_PASSWORD from Step 5. The dashboard is reachable by IP even before you configure DNS, because the appliance's nginx front door routes the VM's own address to the admin interface.

The Users page is where tunnel access tokens live. Your per VM token is already registered as cloudimg-default:

Expose dashboard tunnel access tokens

The Settings page confirms the security posture this image ships with. Require authentication tokens is enabled, so the server will only accept a tunnel from a client presenting a valid token:

Expose dashboard server settings with token validation enabled

Because your browser trusts only its own per VM self signed certificate at this point, you will see a certificate warning until you complete Step 10.

Step 8: Publish a local site through the tunnel

On your own workstation (not the VM), install the Expose client and point it at your server. The client is a single file:

curl -Ls https://github.com/exposedev/expose/releases/latest/download/expose -o expose
chmod +x expose
./expose token <auth-token>
./expose share http://localhost:3000 --server-host=your-domain.com --server-port=443 --subdomain=demo

Use the EXPOSE_AUTH_TOKEN from Step 5 as <auth-token>. The client prints the public URL it has been given:

An Expose client connecting and receiving its public URL

Back in the dashboard, the Sites page now lists the live tunnel, showing which local address it points at and which token opened it:

Expose dashboard showing a live tunnel on the Sites page

Anyone with the public URL now reaches the site running on your workstation:

A local development site served through the Expose tunnel

This is the whole point of the product: the page above is served from a laptop, over a tunnel, through a server you own.

Step 9: Confirm authentication is enforced

The admin API rejects anything without your per VM credential. Without credentials:

curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/users

Expected output:

401

With the per VM password from Step 5:

curl -k -s -o /dev/null -w "%{http_code}\n" -u "admin:<admin-password>" https://127.0.0.1/api/users

Expected output:

200

The local site served through the tunnel, and authentication enforced

The same screenshot shows the upstream project's published default login being rejected with 401. That credential does not exist on this image, and the expose-server service refuses to start if it is ever put back in place.

Step 10: Enable a real TLS certificate

The image generates a self signed certificate per VM so the dashboard and tunnels are encrypted immediately. Once your DNS from Step 6 resolves, replace it with a publicly trusted wildcard certificate. A wildcard is required because every tunnel gets its own subdomain, and it must be issued with a DNS-01 challenge:

sudo snap install --classic certbot
sudo certbot certonly --manual --preferred-challenges dns \
  -d your-domain.com -d '*.your-domain.com'
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem /etc/expose/tls/expose.crt
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem   /etc/expose/tls/expose.key
sudo chown root:expose /etc/expose/tls/expose.crt /etc/expose/tls/expose.key
sudo chmod 0644 /etc/expose/tls/expose.crt
sudo chmod 0640 /etc/expose/tls/expose.key
sudo systemctl reload nginx

Persistence, first boot and updates

Registered tunnel tokens, connected site history and request logs live in a SQLite database at /var/lib/expose/expose.db, which is created on first boot. Your credentials live in /etc/expose/expose.env (read by the service) and /root/expose-info.txt (your copy). Configuration that is not secret lives in /etc/expose/expose-server.php, which deliberately contains no credential: it reads the dashboard login from expose.env at runtime.

To add another tunnel token, use Add user on the dashboard's Users page, or POST /api/users. To rotate the dashboard password, edit EXPOSE_ADMIN_PASSWORD in /etc/expose/expose.env and restart expose-server. The start time guard rejects short or published default values, so choose a long random password.

The base image has unattended security upgrades enabled. Expose itself is pinned to the release verified for this image; upgrade it by replacing /opt/expose/app from a newer upstream release.

Support

Every cloudimg image includes 24/7 support with a 24h response SLA. Contact support@cloudimg.co.uk quoting the image name and your Azure VM ID.