Outline VPN Server
Overview
Outline is an open source access system from Jigsaw, a unit of Google, that gives people a private, hard-to-block route to the open internet. It has 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 runs a Shadowsocks instance that carries client traffic. It also exposes a REST management API that creates, names, lists and revokes access keys. You generate one access key per person or device and share it, and the client connects with nothing else. Revoking a key affects only that key.
The SecureAnyCloud image runs the official Shadowbox 1.12.3 container, pinned by registry digest, as a systemd unit on Ubuntu 24.04 LTS.
What you get:
- 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 automatically replaces the Shadowbox image and mounts the Docker socket to do it. This image removes it, so 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, patched at build time, with unattended security updates left enabled
There is no web interface. Administer Outline Server from the separate Outline Manager desktop application or from the command line. The outline-keys command lets you do everything over SSH without installing anything.
The security model drives the whole design. The management API has no username and no password. Two things protect it: 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 private key. Anyone with the image could then administer everyone's server. In this image both are generated on the first boot of every machine, never at build time, and the service cannot start before they exist.
Prerequisites
You need 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. The recommended VM size is 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 choose a larger size if you need more aggregate bandwidth. The image ships no swap, because Azure image certification rejects swap on the captured OS disk.
If you prefer a graphical console, install the Outline Manager application from getoutline.org. It is optional: everything in this guide works over SSH.
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 SecureAnyCloud.
- Select the offer, then select 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.
- Select Review + create, then Create.
Deploy from the Azure CLI
Run the following to create the network resources, accept the Marketplace terms and create the VM:
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 image terms accept --urn sac1647283583153:outline-vpn-server-ubuntu-24-04:default:latest
az vm create \
--resource-group outline-rg \
--name outline-server \
--image sac1647283583153: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 StandardOpen 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, because Shadowsocks needs both.
Restrict the management port to the addresses you administer from. Client traffic comes from wherever 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 60444Connect over SSH
Connect to the VM over SSH as azureuser:
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.
Confirm the service and check every open port
First boot takes a few seconds, because it generates an RSA-4096 keypair before the service is allowed to start. Check the services and the container:
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 secondsAll three are active. outline-vpn-server-firstboot.service stays active after it finishes because it is a oneshot with RemainAfterExit=yes. It is safe to run again: it detects that the machine is already bootstrapped and exits without changing anything.
Now list 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 does not listen on any port.
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 -uExpected 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:9092Read this machine's connection details
The outline-keys server command asks the management API about itself. Note the version and metricsEnabled fields.
sudo outline-keys serverExpected 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 and 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. Every access key you create embeds this address.
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.txtExpected output follows. The API path and the fingerprint are redacted 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=0The container is pinned by digest, and there is no automatic updater. Confirm both:
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: 0See the security model in action
This check is worth doing once, because it shows the whole security model. 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.keyExpected 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.keyNow prove that the random path is the credential. Send 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 200A single wrong character returns 404. There is no login page to brute force and no username to guess. This is why you must never share or log the path.
The certificate is the second half of the model. It is self-signed, so Outline Manager does not validate a chain. It pins the fingerprint instead. Confirm that 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 -subjectExpected output follows. Your Common Name is your VM's short hostname.
fingerprints match: this is this machine's own certificate subject=CN = outline-serverThe 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 FQDN is routinely longer. The FQDN, the private address and the public address therefore travel in a subjectAltName instead.
Create your first access key
The image ships with no access keys, so nothing is shared between deployments. List the keys, then create your first one:
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 its owner over a channel you trust, and treat it like a password. Creating the first key also 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 -uExpected output:
tcp *:60444 udp *:60444Both protocols are listening, 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.
Add the server to Outline Manager
Outline Manager takes a single JSON line that contains the API URL and the certificate fingerprint. Print it:
sudo outline-keys configExpected output follows. 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 connects and verifies the certificate fingerprint. 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.
Connect a client and revoke a key
Install the Outline client for the user's platform from getoutline.org, then paste the accessUrl from Step 8 into it. The client shows the server and a connect toggle. Nothing else needs configuring.
Revoking a key takes effect immediately and affects only that key:
ID="$(sudo outline-keys list | jq -r '.[0].id')"
sudo outline-keys rm "$ID"
sudo outline-keys listExpected 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 gone, so it never reports success while a key is still live. Revoking an ID that does not exist exits with a non-zero status instead of 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'; fiExpected output:
refused: no such access keyReach the management API from your workstation
Anything Outline Manager does, you can also do with curl. On the server, read your API URL with sudo outline-keys url. Then, from your own machine, run:
# <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 the certificate instead, copy /opt/outline/persisted-state/shadowbox-selfsigned.crt off the server and pass it with --cacert. Alternatively, compare the fingerprint against OUTLINE_CERT_SHA256 from your credentials file.
Never send the API path over an untrusted channel, because it is the credential.
Use a DNS name instead of an IP address
Access keys embed whatever address the machine resolved for itself at first boot. You may prefer keys that point at a hostname, or your machine may sit behind a load balancer or NAT address that metadata cannot discover. In either case, write the address you want before the machine's first boot, for example through Azure custom data. First boot uses 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-hostnameIf the machine has already bootstrapped, change hostnameForAccessKeys through the API instead, and re-issue your keys. Existing keys keep the old address.
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 firewall rules. Remember that 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 8443Changing the management port changes your API URL. Re-add the server in Outline Manager afterwards, using the new output of sudo outline-keys config.
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
Docker API: no port. 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. As a result:
- 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. A reboot 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 is enforced, not assumed. outline-vpn-server.service carries ConditionPathExists on a bootstrap-ready marker. First boot creates the marker 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 never pulls a different image. The auto-updating Watchtower container that the upstream installer normally adds is not present. Updating is therefore a deliberate act. SecureAnyCloud 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.serviceIf the service is not active and the firstboot unit is also not active, first boot did not finish. The bootstrap-ready marker is then absent, and the service correctly refuses to start. Look at what first boot logged:
sudo tail -n 20 /var/log/sac/sac-firstboot.logOutline Manager will not connect. Work through these checks in order:
- Confirm that the network security group allows
60443/tcpfrom your address. - Confirm that
sudo outline-keys configprints a line whose address matches the public address you are connecting to. - Confirm that 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. The cause is almost always a missing UDP rule. Port 60444 needs both TCP and UDP. Confirm that 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" || trueExpected output on a machine with no keys yet follows. The Shadowsocks port opens only once at least one key exists, so an absent listener here is correct and not a fault.
access keys on this machine: 0 no listener expected while there are no access keysWith 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. The path is stored on the machine and cannot be recovered from a client. Read it with sudo outline-keys url. If you have also lost SSH access, the server cannot be recovered. Launch a new one and re-issue your keys.
Support
SecureAnyCloud maintains this image and includes 24/7 support. Outline and Jigsaw are trademarks of Google LLC. SecureAnyCloud is not affiliated with or endorsed by Google. Outline Server is distributed under the Apache License 2.0.
Deploy on Azure
Launch Outline Server on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.
View this image
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com