stunnel TLS Proxy on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of stunnel on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. stunnel is a TLS proxy: it wraps an existing plaintext TCP service in encryption without any change to that service. In server mode it accepts TLS connections and forwards the decrypted stream to a local or remote backend. In client mode it does the reverse, accepting plaintext locally and speaking TLS outwards. That makes it the standard way to put modern, hardened TLS in front of a database, a legacy application, an appliance, or anything else that has no encryption of its own.
The image builds stunnel 5.79 from the pinned upstream source (www.stunnel.org/downloads/stunnel-5.79.tar.gz), with the tarball verified against the upstream published SHA-256 during the build, so the exact version is reproducible and there is no third party package repository on the running VM. stunnel runs under systemd as the unprivileged stunnel user. Unattended security upgrades keep the base OS patched.
Secure by design, with nothing baked in. A TLS proxy whose private key shipped inside the image would give every customer the same key, so this image never ships key material. On the very first boot of every VM, a fresh 256 bit pre shared key and a fresh TLS certificate and private key are generated, unique to that instance, and recorded in /root/stunnel-credentials.txt (mode 0600, root only). The daemon is held behind a bootstrap gate until that has happened, so it can never start with missing or shared key material.
The default tunnel demands a credential before it will even complete a handshake. The [ssh-tls] tunnel uses stunnel's native pre shared key authentication, so a client that does not hold this VM's identity and key is rejected during the TLS handshake itself, before any byte reaches the backend. A second tunnel, [ssh-tls-cert], demonstrates classic certificate based TLS termination using the per instance certificate.
What is included:
-
stunnel 5.79 built from the pinned, checksum verified upstream source, run under systemd as the unprivileged
stunneluser (stunnel.service) -
A pre shared key protected TLS tunnel on TCP 8443 and a certificate based TLS termination endpoint on TCP 8444, both wired to this VM's SSH service as a working demonstration backend you repoint at your own service
-
A per instance pre shared key and a per instance TLS certificate and private key generated on first boot, documented in
/root/stunnel-credentials.txt(0600) -
A hardened baseline: TLS 1.2 minimum, unprivileged runtime user, and
CAP_NET_BIND_SERVICEgranted so you can move a tunnel to a privileged port such as 443 without editing the unit -
Everything driven from one short, commented configuration file at
/etc/stunnel/stunnel.conf
Prerequisites
-
Active Azure subscription, SSH public key, VNet + subnet in target region
-
Subscription to the stunnel listing on Azure Marketplace
-
Network Security Group rules allowing TCP 22 (admin) and, for the clients that will use the tunnels, TCP 8443 and/or TCP 8444
Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM). Encryption throughput scales with CPU, so busy tunnels should use Standard_D2s_v5 or larger.
Step 1: Deploy from the Azure Portal
Search stunnel in Marketplace, select the cloudimg publisher, and click Create. Configure the Network Security Group to allow TCP 8443 (and TCP 8444 if you will use the certificate endpoint) from your client networks, and TCP 22 for administration.
Step 2: Deploy from the Azure CLI
RG="tls-prod"; LOCATION="eastus"; VM_NAME="tls1"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/stunnel-ubuntu-24-04/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az vm create \
--resource-group "$RG" --name "$VM_NAME" \
--image "$GALLERY_IMAGE_ID" \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values "$SSH_KEY" \
--public-ip-sku Standard
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 8443 --priority 1001
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 22 --priority 1002
Step 3: First boot
On first boot the image generates this VM's pre shared key and TLS certificate, writes them to /etc/stunnel/psk.txt and /etc/stunnel/certs/stunnel.pem, records both in /root/stunnel-credentials.txt, and only then releases stunnel.service to start. This completes within a few seconds. SSH in as azureuser and read the details:
sudo cat /root/stunnel-credentials.txt
The file names your pre shared key identity, the 64 hexadecimal character key, the certificate fingerprint, and the two tunnel endpoints. Keep it root only; it is the one place the plaintext key exists.
Step 4: Confirm the service is running
stunnel.service runs under systemd as the unprivileged stunnel user. ss confirms both tunnels are listening.
systemctl is-active stunnel.service
ps -o user= -C stunnel | head -1
ss -tlnp | grep -E ':8443 |:8444 '
Expected output:
active
stunnel
LISTEN 0 4096 0.0.0.0:8443 0.0.0.0:* users:(("stunnel",pid=9769,fd=8))
LISTEN 0 4096 0.0.0.0:8444 0.0.0.0:* users:(("stunnel",pid=9769,fd=9))

You can also confirm the build, including that pre shared key support is compiled in:
timeout 10 stunnel -version </dev/null 2>&1 | sed -n '1,4p'
Initializing inetd mode configuration
stunnel 5.78 on x86_64-pc-linux-gnu platform
Compiled/running with OpenSSL 3.0.13 30 Jan 2024
Threading:PTHREAD Sockets:POLL,IPv6 TLS:ENGINE,OCSP,PSK,SNI,DTLS
The TLS: line confirms PSK support. The banner reads 5.78 because the upstream 5.79 release ships a stale version constant in src/version.h; the source built here is the 5.79 release, verified by its upstream SHA-256 at build time, and cloudimg does not patch upstream source to correct cosmetic strings.
Step 5: The tunnel requires this VM's pre shared key
This is the security property that matters. On port 8443 the pre shared key is checked during the TLS handshake, so a client without it never establishes a session and never reaches the backend. Read your key from the credentials file, then prove all three cases. These commands run on the VM against loopback; from a remote client, replace 127.0.0.1 with the VM address.
PSK_IDENTITY=$(sudo grep '^PSK_IDENTITY=' /root/stunnel-credentials.txt | cut -d= -f2-)
PSK_KEY=$(sudo grep '^PSK_KEY=' /root/stunnel-credentials.txt | cut -d= -f2-)
echo -n 'no shared key : '; timeout 8 openssl s_client -connect 127.0.0.1:8443 -quiet -verify_quiet </dev/null 2>/dev/null | head -1 | grep . || echo 'handshake refused'
echo -n 'wrong shared key : '; timeout 8 openssl s_client -connect 127.0.0.1:8443 -quiet -verify_quiet -psk_identity "$PSK_IDENTITY" -psk 00000000000000000000000000000000 </dev/null 2>/dev/null | head -1 | grep . || echo 'handshake refused'
echo -n 'correct key : '; timeout 8 openssl s_client -connect 127.0.0.1:8443 -quiet -verify_quiet -psk_identity "$PSK_IDENTITY" -psk "$PSK_KEY" </dev/null 2>/dev/null | head -1 | grep . || echo 'handshake refused'
Expected output:
no shared key : handshake refused
wrong shared key : handshake refused
correct key : SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.18
The final line is the backend's own greeting arriving through the encrypted tunnel, which is proof the whole path works end to end.

Step 6: Certificate based TLS termination
Port 8444 shows the other pattern, the one most people mean by TLS termination: stunnel presents this VM's certificate, negotiates TLS, and forwards the decrypted stream to the backend. Copy this block in the configuration file to put TLS in front of your own service.
echo -n 'certificate tunnel : '; timeout 8 openssl s_client -connect 127.0.0.1:8444 -quiet -verify_quiet </dev/null 2>/dev/null | head -1 | grep . || echo 'no response'
SERVED_CERT=$(timeout 8 openssl s_client -connect 127.0.0.1:8444 -verify_quiet </dev/null 2>/dev/null | openssl x509 2>/dev/null)
printf '%s\n' "$SERVED_CERT" | openssl x509 -noout -subject -dates -fingerprint -sha256
The certificate is self signed and unique to this VM, so its fingerprint matches the one recorded in /root/stunnel-credentials.txt. For production, replace /etc/stunnel/certs/stunnel.pem with your own certificate and key concatenated into one file, keep it 0640 root:stunnel, and reload the service.
Step 7: SSH through the TLS tunnel from your workstation
Because the demonstration backend is this VM's own SSH service, you can drive the tunnel end to end from a client machine. openssl s_client becomes the SSH transport, so the SSH session travels inside TLS. Replace <vm-ip> with the VM address, and take the identity and key from /root/stunnel-credentials.txt.
ssh -o ProxyCommand="openssl s_client -quiet -connect <vm-ip>:8443 -psk_identity PSK_IDENTITY -psk PSK_KEY" azureuser@<vm-ip>
Substitute PSK_IDENTITY and PSK_KEY with the values from the credentials file. This is a useful pattern in its own right: it presents SSH as an ordinary TLS service, and the pre shared key stops anyone who does not hold it from reaching the SSH port at all.
Step 8: Review the configuration
Everything is driven from one short file. The global block sets the TLS floor, and each [section] is one tunnel.
grep -vE '^\s*;|^\s*$' /etc/stunnel/stunnel.conf
Expected output:
foreground = yes
pid =
output = /var/log/stunnel/stunnel.log
debug = notice
syslog = no
sslVersionMin = TLSv1.2
[ssh-tls]
accept = 0.0.0.0:8443
connect = 127.0.0.1:22
PSKsecrets = /etc/stunnel/psk.txt
[ssh-tls-cert]
accept = 0.0.0.0:8444
connect = 127.0.0.1:22
cert = /etc/stunnel/certs/stunnel.pem

Step 9: Put TLS in front of your own service
To wrap one of your own services, change the connect address of a tunnel, or add a new [section]. Anything that speaks plain TCP works, whether it runs on this VM or elsewhere in your virtual network. For example, to add a TLS front door on port 9443 for a PostgreSQL server elsewhere in your virtual network, replacing <private-ip> with that server's address:
printf '\n[postgres-tls]\naccept = 0.0.0.0:9443\nconnect = <private-ip>:5432\ncert = /etc/stunnel/certs/stunnel.pem\n' | sudo tee -a /etc/stunnel/stunnel.conf >/dev/null
sudo systemctl reload stunnel
Then open TCP 9443 in the Network Security Group for the clients that need it. Add PSKsecrets = /etc/stunnel/psk.txt to a section if you want that tunnel to demand the pre shared key as well as, or instead of, presenting a certificate.
For the reverse direction, client = yes in a section makes stunnel accept plaintext locally and connect outwards over TLS, which is how you give a legacy application an encrypted path to a remote TLS endpoint.
Step 10: Confirm no key material ships in the image
No default, shared, or baked in key material exists. The credentials file is 0600 root:root, and both the pre shared key file and the certificate bundle are 0640 root:stunnel, readable only by root and the service account.
sudo stat -c 'file: %n perms: %a owner: %U:%G' /root/stunnel-credentials.txt /etc/stunnel/psk.txt /etc/stunnel/certs/stunnel.pem
sudo sed -E 's/^(PSK_KEY=.{6}).*/\1********************/' /root/stunnel-credentials.txt | grep -E '^PSK_IDENTITY=|^PSK_KEY='
Expected output:
file: /root/stunnel-credentials.txt perms: 600 owner: root:root
file: /etc/stunnel/psk.txt perms: 640 owner: root:stunnel
file: /etc/stunnel/certs/stunnel.pem perms: 640 owner: root:stunnel

Step 11: Logs and troubleshooting
stunnel logs to /var/log/stunnel/stunnel.log and to the systemd journal. Connection level detail, including rejected handshakes, appears there.
sudo tail -n 20 /var/log/stunnel/stunnel.log
sudo journalctl -u stunnel.service --no-pager -n 20
If a tunnel does not come up, check the configuration is well formed and that the backend named in connect is actually listening. Raise debug = notice to debug = debug for a verbose trace while diagnosing, then put it back.
Step 12: Security recommendations
-
Restrict the NSG. Allow TCP 8443 and 8444 only from the client networks that need them, and TCP 22 for administration only.
-
Keep the pre shared key requirement on any tunnel exposed beyond your virtual network. It is what stops an unauthenticated peer from reaching the backend at all.
-
Replace the self signed certificate for production. Use a certificate from your own CA or a public CA, concatenate the private key and certificate into
/etc/stunnel/certs/stunnel.pem, keep it0640 root:stunnel, and reload. -
Rotate the pre shared key by writing a new
identity:keyline to/etc/stunnel/psk.txtand reloading; the key must be at least 32 hexadecimal characters and every client must be updated at the same time. -
Keep the TLS floor at 1.2 or raise it. Set
sslVersionMin = TLSv1.3if every client supports it. -
Do not point a tunnel at a backend you do not control, and keep
connectaddresses inside your own network wherever possible. -
Keep the OS patched. Unattended security upgrades remain enabled on the running VM.
Step 13: Support and Licensing
stunnel is developed by Michal Trojnara and distributed under the GNU General Public License version 2 or later, with the copyright holder's explicit exception permitting linking with OpenSSL. This cloudimg image builds the unmodified upstream stunnel 5.79 source. cloudimg provides the packaging, the secure default configuration, the per instance key material automation, security patching, and 24/7 support with a guaranteed 24 hour response SLA.
cloudimg is not affiliated with or endorsed by the stunnel project. stunnel is a mark of its respective owner and is used here only to identify the software.
Deploy on Azure
Find stunnel TLS Proxy on Ubuntu 24.04 LTS on the Azure Marketplace, published by cloudimg. Deploy from the Portal or the Azure CLI as shown above.
Need Help?
Email support@cloudimg.co.uk for deployment help, configuration questions, or licensing enquiries.