Outline Server on Ubuntu 24.04 on Azure User Guide
Overview
Outline is an open source access system from Jigsaw, a unit of Google. It exists to give people a private, hard to block route to the open internet. There are two halves: the Outline client applications for Windows, macOS, Linux, Android, iOS and ChromeOS, and the Outline Server, known upstream by its container name Shadowbox. This image is the server half.
An Outline Server does two things. It runs a Shadowsocks instance that carries client traffic, and it exposes a REST management API that creates, names, lists and revokes access keys. You generate one access key per person or per device, share the key, and the client connects with nothing else. Revoking a key affects only that key.
The cloudimg image runs the official Shadowbox 1.12.3 container, pinned by registry digest, as a systemd unit on Ubuntu 24.04 LTS.
There is no web interface, and this guide does not pretend otherwise. Outline Server is administered either from the separate Outline Manager desktop application, or from the command line. This image includes an outline-keys command so everything you need can be done over SSH without installing anything.
The security model is unusual, and it drives the whole design. The management API has no username and no password. It is protected by two things: a 128 bit random path prefix in the URL, which acts as the credential, and a self signed TLS certificate whose SHA-256 fingerprint Outline Manager pins. If either were baked into an image, every customer who launched it would share the same API path and the same private key, and anyone with the image could administer everyone's server. So in this image both are generated on the first boot of every machine and never at build time, and the service physically cannot start before they exist.
What is included:
- Outline Server (Shadowbox)
1.12.3, pinned by digestsha256:545c6f7c...5444e62, never a moving tag outline-vpn-server.service, asystemdunit that owns the container in the foregroundoutline-vpn-server-firstboot.service, which generates this machine's API path, RSA-4096 self signed certificate, certificate fingerprint and connection details- An
outline-keyscommand for listing, creating, naming and revoking access keys over SSH - Zero access keys, so nothing is shared between deployments and you create your own first key
- No Watchtower. The upstream installer adds a container that auto replaces the Shadowbox image and mounts the Docker socket to do it. That is removed here: the version you certify is the version that keeps running
- Anonymous usage reporting switched off
- Two public ports by design, with the bundled Prometheus and every metrics endpoint bound to loopback
- Docker CE with its stock socket only configuration, so the Docker API is not reachable over the network
- Ubuntu 24.04 LTS, fully patched, with unattended security updates left enabled
Prerequisites
An active Azure subscription, an SSH key pair, and a virtual network with a subnet.
Outline Server is light: the container is a small Node process plus a Go Shadowsocks server. Recommended VM size: Standard_B2s (2 vCPU, 4 GB RAM), which is comfortable for a team. Throughput is bounded by the VM's network bandwidth rather than by CPU, so scale up the size if you need more aggregate bandwidth. This image ships no swap, because Azure image certification rejects swap on the captured OS disk.
You will also need the Outline Manager application if you prefer a graphical console, from getoutline.org. It is optional: everything in this guide works over SSH.
Step 1: Deploy from the Azure Portal
- In the Azure Portal choose Create a resource and search the Marketplace for Outline Server on Ubuntu 24.04 by cloudimg.
- Select the offer, then Create.
- On Basics pick your subscription, resource group and region, set the VM name, and choose size Standard_B2s.
- For Authentication type choose SSH public key, set the username to
azureuser, and supply your public key. - On Disks leave the OS disk as Standard SSD or better.
- On Networking select your virtual network and subnet. Allow SSH (22) for now; Step 3 adds the two Outline ports.
- Review + create, then Create.
Step 2: Deploy from the Azure CLI
az group create --name outline-rg --location eastus
az network vnet create --resource-group outline-rg --name outline-vnet \
--address-prefix 10.20.0.0/16 --subnet-name outline-subnet --subnet-prefix 10.20.1.0/24
az network nsg create --resource-group outline-rg --name outline-nsg
az vm create \
--resource-group outline-rg \
--name outline-server \
--image cloudimg:outline-vpn-server-ubuntu-24-04:default:latest \
--size Standard_B2s \
--storage-sku StandardSSD_LRS \
--admin-username azureuser \
--generate-ssh-keys \
--vnet-name outline-vnet --subnet outline-subnet --nsg outline-nsg \
--public-ip-sku Standard
Step 3: Open the two Outline ports
Outline needs exactly two ports open, and nothing else. 60443/tcp carries the management API that Outline Manager talks to. 60444/tcp and 60444/udp carry client traffic; Shadowsocks needs both.
Restrict the management port to the addresses you administer from. Client traffic has to come from anywhere your users are, so the access key port stays open to the internet.
# management API — lock this down to the network you administer from
az network nsg rule create --resource-group outline-rg --nsg-name outline-nsg \
--name allow-outline-management --priority 1010 \
--access Allow --protocol Tcp --direction Inbound \
--source-address-prefixes <your-mgmt-cidr> --destination-port-ranges 60443
# access keys — your users connect from anywhere, over TCP and UDP
az network nsg rule create --resource-group outline-rg --nsg-name outline-nsg \
--name allow-outline-keys-tcp --priority 1020 \
--access Allow --protocol Tcp --direction Inbound \
--source-address-prefixes '*' --destination-port-ranges 60444
az network nsg rule create --resource-group outline-rg --nsg-name outline-nsg \
--name allow-outline-keys-udp --priority 1030 \
--access Allow --protocol Udp --direction Inbound \
--source-address-prefixes '*' --destination-port-ranges 60444
Step 4: Connect over SSH
ssh -i ~/.ssh/id_rsa azureuser@<vm-ip>
The login banner shows the two ports and how to read your connection details. It deliberately does not print the management API path, because that path is a credential.
Step 5: Confirm the service and check the whole port surface
First boot takes a few seconds: it has to generate an RSA-4096 keypair before the service is allowed to start.
sudo systemctl is-active docker.service outline-vpn-server.service outline-vpn-server-firstboot.service
sudo docker ps --format 'table {{.Names}}\t{{.Status}}'
Expected output:
active
active
active
NAMES STATUS
shadowbox Up 6 seconds
All three are active. outline-vpn-server-firstboot.service stays active after it finishes because it is a oneshot with RemainAfterExit=yes, and it is safe to run again: it detects that this machine is already bootstrapped and exits without touching anything.
Now look at every socket the machine is listening on. Only SSH and the two Outline ports are reachable from the internet; the bundled Prometheus, Outline's own node metrics and the Shadowsocks metrics endpoint are all bound to loopback, and the Docker API is not listening on any port at all.
echo 'PUBLIC (reachable from the internet):'
sudo ss -H -lntu | awk '{p=$1;a=$5; if(a ~ /^127\./ || a ~ /^\[::1\]/ || a ~ /%lo/) next; n=split(a,x,":"); printf " %-4s %-20s\n", p, a}' | sort -u
echo 'LOOPBACK ONLY:'
sudo ss -H -lntu | awk '{p=$1;a=$5; if(!(a ~ /^127\./ || a ~ /^\[::1\]/ || a ~ /%lo/)) next; n=split(a,x,":"); if(x[n]>=9090 && x[n]<=9092) printf " %-4s %-20s\n", p, a}' | sort -u
Expected output on a machine with no access keys yet (the access key port opens when you create your first key):
PUBLIC (reachable from the internet):
tcp *:60443
tcp 0.0.0.0:22
udp 10.20.1.4%eth0:68
LOOPBACK ONLY:
tcp 127.0.0.1:9090
tcp 127.0.0.1:9091
tcp 127.0.0.1:9092

Step 6: Read this machine's connection details
outline-keys server asks the management API about itself. Note version and metricsEnabled.
sudo outline-keys server
Expected output:
{
"name": "Outline Server",
"serverId": "2557a594-a1fd-4aca-9625-c36a26679063",
"metricsEnabled": false,
"createdTimestampMs": 1785081463977,
"version": "1.12.3",
"portForNewAccessKeys": 60444,
"hostnameForAccessKeys": "52.147.203.56"
}
serverId is unique to your machine, generated on its first boot. metricsEnabled is false, so nothing is reported anywhere unless you opt in. hostnameForAccessKeys is the public address this machine resolved for itself, and it is the address embedded in every access key you create.
The full details, including the management API path and the certificate fingerprint, live in a root only file:
sudo stat -c '%a %U:%G %n' /root/outline-vpn-server-credentials.txt
sudo sed -E 's@(:60443/)[A-Za-z0-9_-]+@\1API-PATH-REDACTED@g; s@(SHA256=[0-9A-F]{12})[0-9A-F]+@\1...REDACTED@' /root/outline-vpn-server-credentials.txt
Expected output (the API path and the fingerprint are redacted above because both are secrets; run sudo cat on the file to see your real values):
600 root:root /root/outline-vpn-server-credentials.txt
# Outline Server (Shadowbox) 1.12.3 - per-VM connection details
# Generated on first boot by outline-vpn-server-firstboot.service.
OUTLINE_API_URL=https://52.147.203.56:60443/API-PATH-REDACTED
OUTLINE_CERT_SHA256=7BFAF80EEA70...REDACTED
OUTLINE_API_PORT=60443
OUTLINE_ACCESS_KEY_PORT=60444
OUTLINE_SERVER_VERSION=1.12.3
OUTLINE_ACCESS_KEYS=0
The container is pinned by digest, and there is no auto updater:
sudo docker inspect shadowbox --format 'running: {{.Config.Image}}'
sudo docker ps -a --format '{{.Image}} {{.Names}}' | grep -ci watchtower || echo 'watchtower containers: 0'
Expected output:
running: quay.io/outline/shadowbox@sha256:545c6f7c7261bb30ae1dffe24a6fca5f8512f5d17c72cfb9e410e7e655444e62
watchtower containers: 0

Step 7: See how the security model actually behaves
This is worth doing once, because it is the whole security story. Every per machine secret is 0600 root:root:
sudo stat -c ' %a %U:%G %n' /root/outline-vpn-server-credentials.txt /etc/outline/outline.env /opt/outline/persisted-state/shadowbox-selfsigned.key
Expected output:
600 root:root /root/outline-vpn-server-credentials.txt
600 root:root /etc/outline/outline.env
600 root:root /opt/outline/persisted-state/shadowbox-selfsigned.key
Now prove that the random path really is the credential. Three wrong requests, then the right one:
PREFIX="$(sudo sed -n 's/^SB_API_PREFIX=//p' /etc/outline/outline.env)"
printf ' %-38s HTTP %s\n' 'no path at all' "$(curl -ks -o /dev/null -w '%{http_code}' https://127.0.0.1:60443/)"
printf ' %-38s HTTP %s\n' 'a random 128-bit path' "$(curl -ks -o /dev/null -w '%{http_code}' https://127.0.0.1:60443/$(openssl rand -hex 11)/server)"
printf ' %-38s HTTP %s\n' 'this path with ONE character changed' "$(curl -ks -o /dev/null -w '%{http_code}' https://127.0.0.1:60443/${PREFIX%?}X/server)"
printf ' %-38s HTTP %s\n' 'this machine own path' "$(curl -ks -o /dev/null -w '%{http_code}' https://127.0.0.1:60443/$PREFIX/server)"
Expected output:
no path at all HTTP 404
a random 128-bit path HTTP 404
this path with ONE character changed HTTP 404
this machine own path HTTP 200
A single wrong character is a 404. There is no login page to brute force and no username to guess, which is exactly why the path must never be shared or logged.
The certificate is the second half. It is self signed, so Outline Manager does not validate a chain: it pins the fingerprint. Confirm the certificate served on the wire is the one this machine generated:
SERVED="$(printf '' | openssl s_client -connect 127.0.0.1:60443 2>/dev/null | openssl x509 -noout -sha256 -fingerprint | sed 's/^.*=//' | tr -d ':')"
RECORDED="$(sudo sed -n 's/^OUTLINE_CERT_SHA256=//p' /root/outline-vpn-server-credentials.txt)"
if [ "$SERVED" = "$RECORDED" ]; then echo "fingerprints match: this is this machine's own certificate"; else echo "MISMATCH"; fi
sudo openssl x509 -in /opt/outline/persisted-state/shadowbox-selfsigned.crt -noout -subject
Expected output (your Common Name is your VM's short hostname):
fingerprints match: this is this machine's own certificate
subject=CN = outline-server
The Common Name is the machine's short hostname, not its full Azure name. X.509 caps a Common Name at 64 characters and an Azure internal FQDN is routinely longer than that, so the FQDN, the private address and the public address all travel in a subjectAltName instead.

Step 8: Create your first access key
The image ships with none, so nothing is shared between deployments:
sudo outline-keys list
sudo outline-keys add "alice laptop"
Expected output:
[]
{
"id": "0",
"name": "alice laptop",
"port": 60444,
"accessUrl": "ss://KEY-SECRET-REDACTED@52.147.203.56:60444/?outline=1"
}
The accessUrl is the whole credential a client needs. Send it to the person it belongs to over a channel you trust, and treat it like a password. Creating the first key is also what opens the Shadowsocks port:
# the listener appears within a second or two of the key being created
for i in $(seq 1 15); do
L="$(sudo ss -H -lnt | awk '{n=split($4,a,":"); print a[n]}')"
case " $L " in *" 60444 "*) break ;; esac
sleep 1
done
sudo ss -H -lntu | awk '$5 ~ /:60444$/ {printf " %-4s %s\n", $1, $5}' | sort -u
Expected output:
tcp *:60444
udp *:60444
Both protocols, as Shadowsocks requires. If you had opened only TCP in Step 3, clients would connect but UDP based traffic such as DNS and video calling would fail.

Step 9: Add the server to Outline Manager
Outline Manager takes a single JSON line containing the API URL and the certificate fingerprint. Print it:
sudo outline-keys config
Expected output (your own values will differ; this is the line to paste):
{"apiUrl":"https://52.147.203.56:60443/API-PATH-REDACTED","certSha256":"7BFAF80EEA70B99FEA0F03C8BF5104CD79AE5A6D2144621DF298B68CB49F482F"}
In Outline Manager choose Set up Outline anywhere, then paste that line into the box in step 2 of its wizard. Manager will connect, verify the certificate fingerprint, and from then on you can manage keys graphically as well as from the command line. Both interfaces drive the same API, so keys created over SSH appear in Manager and the other way round.
Step 10: Connect a client, and revoke a key
Install the Outline client for the person's platform from getoutline.org, then paste the accessUrl from Step 8 into it. The client shows the server and a connect toggle; there is nothing else to configure.
Revoking is immediate and affects only that key:
ID="$(sudo outline-keys list | jq -r '.[0].id')"
sudo outline-keys rm "$ID"
sudo outline-keys list
Expected output:
revoked access key 0
[]
Revocation is deliberately fail closed: outline-keys rm checks the API's status code and re-reads the key list to confirm the key is genuinely gone, so it can never report success while a key is still live. Revoking an id that does not exist exits non zero rather than printing a reassuring message:
if sudo outline-keys rm 99999 >/dev/null 2>&1; then echo 'reported success (would be a bug)'; else echo 'refused: no such access key'; fi
Expected output:
refused: no such access key
Step 11: Reach the management API from your workstation
Anything Outline Manager does, you can do with curl. Read your API URL with sudo outline-keys url on the server, then from your own machine:
# <vm-ip> is your VM's public address; the path after the port is your API path
curl --insecure https://<vm-ip>:60443/YOUR-API-PATH/server
curl --insecure -X POST https://<vm-ip>:60443/YOUR-API-PATH/access-keys
--insecure is required because the certificate is self signed. To verify it properly instead, copy /opt/outline/persisted-state/shadowbox-selfsigned.crt off the server and pass it with --cacert, or compare the fingerprint against OUTLINE_CERT_SHA256 from your credentials file. Never send the API path over an untrusted channel: it is the credential.
Step 12: Use a DNS name instead of an IP address
Access keys embed whatever address the machine resolved for itself at first boot. If you would rather your keys point at a hostname, or your machine sits behind a load balancer or NAT address that metadata cannot discover, write the address you want before the machine's first boot — for example through Azure custom data — and first boot will use it verbatim:
# runs before the first boot of a NEW machine, e.g. via custom data
sudo install -d -m 0700 /etc/outline
echo '<your-domain>' | sudo tee /etc/outline/public-hostname
If the machine has already bootstrapped, change hostnameForAccessKeys through the API instead and re-issue keys. Existing keys keep the old address.
Step 13: Change the ports
The management and access key ports are fixed at 60443 and 60444 so that this guide and your firewall rules can name them. To change them, edit /etc/outline/outline.conf, restart the service, and add matching rules — remember the access key port needs both TCP and UDP:
sudo sed -i 's@^OUTLINE_API_PORT=.*@OUTLINE_API_PORT=8443@' /etc/outline/outline.conf
sudo systemctl restart outline-vpn-server.service
az network nsg rule create --resource-group outline-rg --nsg-name outline-nsg \
--name allow-outline-management-8443 --priority 1011 \
--access Allow --protocol Tcp --direction Inbound \
--source-address-prefixes <your-mgmt-cidr> --destination-port-ranges 8443
Changing the management port changes your API URL, so re-add the server in Outline Manager afterwards using the new sudo outline-keys config output.
Ports and the security model
| Port | Protocol | Reachable from | Purpose |
|---|---|---|---|
| 22 | TCP | your admin network | SSH |
| 60443 | TCP | your admin network | Outline management API, used by Outline Manager and outline-keys |
| 60444 | TCP and UDP | anywhere your users are | Shadowsocks client traffic, opens once an access key exists |
| 9090 | TCP | loopback only | bundled Prometheus |
| 9091 | TCP | loopback only | Outline's own node metrics |
| 9092 | TCP | loopback only | Shadowsocks server metrics |
| — | — | not listening | the Docker API has no network listener at all |
The management API has no username and no password. Its credential is the random path, and its transport identity is a pinned self signed certificate. Consequences worth internalising:
- Treat the API URL as a password. Anyone who has it can create and revoke keys on your server.
- Restrict
60443to your admin network. The path is 128 bits and unguessable, but there is no reason to expose the API to the internet at all. - Every machine is independent. The API path, the private key, the certificate and the
serverIdare all generated on that machine's own first boot, so compromising one deployment tells an attacker nothing about another. - Access keys are per person. Revoke one and only that person loses access.
Persistence, first boot and updates
State lives in /opt/outline/persisted-state, which is 0700 root and holds the certificate, the private key, the server configuration and the Shadowsocks key list. Rebooting keeps everything; outline-vpn-server-firstboot.service sees that the machine is already bootstrapped and does nothing.
The order of operations on a brand new machine matters and is enforced rather than hoped for. outline-vpn-server.service carries ConditionPathExists on a bootstrap ready marker that first boot creates only after the API path and the keypair exist, so the service cannot serve a request with anything but this machine's own secrets. There is deliberately no After or Before relationship between the two units in either direction, because either one deadlocks; the marker is the only ordering primitive.
The Shadowbox container is pinned by digest, so docker will never pull a different image, and the auto updating Watchtower container that the upstream installer normally adds is not present. Updating is therefore a deliberate act: cloudimg publishes a new image version when upstream releases one, and you move to it by launching a machine from the new version and re-issuing keys. Ubuntu security updates continue to install automatically through unattended-upgrades.
Troubleshooting
The service is not running. Check the unit and the container:
sudo systemctl is-active outline-vpn-server.service
sudo docker ps -a --format 'table {{.Names}}\t{{.Status}}'
sudo systemctl is-active outline-vpn-server-firstboot.service
If the service is inactive and the firstboot unit is also not active, first boot did not finish, so the bootstrap ready marker is absent and the service is correctly refusing to start. Look at what first boot logged:
sudo tail -n 20 /var/log/cloudimg-firstboot.log
Outline Manager will not connect. In order: confirm 60443/tcp is allowed from your address in the network security group; confirm sudo outline-keys config prints a line whose address matches the public address you are dialling; and confirm the fingerprint in that line matches the certificate the server serves, using the check in Step 7. A fingerprint mismatch means you are pointing Manager at a different machine.
A client connects but some traffic fails. Almost always a missing UDP rule. 60444 needs both TCP and UDP; confirm both listeners exist and both rules are present:
N="$(sudo outline-keys list | jq 'length')"
echo "access keys on this machine: ${N}"
sudo ss -H -lntu | awk '$5 ~ /:60444$/ {printf " listening: %-4s %s\n", $1, $5}' | sort -u
[ "${N}" = "0" ] && echo " no listener expected while there are no access keys" || true
Expected output on a machine with no keys yet — the Shadowsocks port only opens once at least one key exists, so an absent listener here is correct rather than a fault:
access keys on this machine: 0
no listener expected while there are no access keys
With at least one key you should see both tcp and udp on 60444. If you see only tcp, the UDP network security group rule from Step 3 is missing.
I lost the API path. It is on the machine, not recoverable from a client. Read it with sudo outline-keys url. If you have lost SSH access as well, the server cannot be recovered and you should launch a new one and re-issue keys.
Support
This image is maintained by cloudimg with 24/7 support included. Outline and Jigsaw are trademarks of Google LLC; cloudimg is not affiliated with or endorsed by Google. Outline Server is distributed under the Apache License 2.0.