Application Infrastructure Azure

tusd Resumable Upload Server on Ubuntu 24.04 on Azure User Guide

| Product: tusd Resumable Upload Server on Ubuntu 24.04 LTS on Azure

Overview

tusd is the official reference server implementation of tus, an open protocol for resumable file uploads over HTTP. Instead of sending a file in one request that has to start again from zero whenever it fails, a tus client creates an upload, then appends bytes to it. If the connection drops - a flaky mobile network, a closed laptop lid, a proxy timeout - the client asks the server how many bytes actually arrived and continues from exactly that offset. That makes tus a good fit for large media, backups, scientific datasets and any upload from an unreliable network.

The cloudimg image installs the pinned tusd 2.10.0 single Go binary running under systemd with its local filesystem storage backend, so there is no external database to operate. It is then locked down for a marketplace appliance, because an open upload endpoint is an abuse vector - anonymous storage, malware hosting and disk exhaustion. tusd is bound to loopback 127.0.0.1:8080 so it is never exposed directly, and an nginx reverse proxy on port 80 puts a per-VM HTTP Basic Auth gate in front of it. tusd has no user accounts of its own, so that credential - user admin, with a unique password generated on the first boot of your VM - is the gate, and it applies to every tus method: creating an upload, querying its offset, appending bytes, downloading it and deleting it. Uploads are capped per upload and stored on a dedicated Azure data disk mounted at /var/lib/tusd. Backed by 24/7 cloudimg support.

What is included:

  • tusd 2.10.0 installed as a single Go binary and running as the tusd systemd service
  • The tus 1.0.0 endpoint at /files/ on port 80, fronted by nginx with the binary bound to loopback only
  • Per-VM HTTP Basic Auth (user admin) protecting every tus method, with a unique password generated on first boot
  • tusd bound to 127.0.0.1:8080 and running as an unprivileged tusd system user
  • A 5 GiB per-upload size cap, advertised to clients as Tus-Max-Size, so no single upload can exhaust the disk
  • The creation, creation-with-upload, termination, concatenation and creation-defer-length tus extensions
  • A dedicated Azure data disk at /var/lib/tusd holding all uploaded files and their metadata sidecars
  • tusd.service + nginx.service as systemd units, enabled and active
  • An unauthenticated /healthz endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet + subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a comfortable starting point; tusd is light on CPU and memory, and upload throughput is bound by network and disk. NSG inbound: allow 22/tcp from your management network, 80/tcp for the tus endpoint, and 443/tcp if you add TLS. The appliance serves plain HTTP on port 80; for production use, terminate TLS in front of it with your own domain and restrict access to trusted IP ranges (see Maintenance).

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for tusd by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Review the dedicated data disk on the Disks tab - this is where your uploads are stored, so size it for your workload - then Review + create -> Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name tusd \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --vnet-name <your-vnet> --subnet <your-subnet> \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name tusd --port 80 --priority 1010

Step 3 - Connect to your VM

ssh azureuser@<vm-public-ip>

Step 4 - Confirm the services are running

systemctl is-active tusd.service nginx.service
findmnt -no SOURCE,TARGET,FSTYPE,SIZE /var/lib/tusd

Both services report active. tusd serves the tus protocol on the loopback address 127.0.0.1:8080; nginx fronts it on port 80 and adds the per-VM HTTP Basic Auth gate. Because tusd binds loopback only, nginx is the sole path in - nothing reaches tusd without passing the auth gate first. Your uploads live on the dedicated Azure data disk mounted at /var/lib/tusd.

The tusd and nginx services active, tusd version 2.10.0, tusd listening only on loopback 127.0.0.1:8080 while nginx holds the single public port 80, and the dedicated data disk mounted at /var/lib/tusd

Step 5 - Retrieve your upload endpoint password

nginx protects the tus endpoint with HTTP Basic Auth. The username is admin and a unique password is generated on the first boot of your VM and written to a root-only file:

sudo cat /root/tusd-credentials.txt

This file contains TUSD_USERNAME, TUSD_PASSWORD, the TUSD_ENDPOINT to point your tus client at, and the TUSD_MAX_UPLOAD_BYTES cap. The password is stored on disk only as a bcrypt hash in /etc/nginx/.tusd.htpasswd, so no plaintext password ships in the image and no two VMs share a credential. Store the password somewhere safe.

TUSD_URL and TUSD_ENDPOINT show the VM's internal address, because Azure's Instance Metadata Service does not report a Standard SKU public IP back to the VM that owns it. Use that address for clients inside the same VNet; from outside, substitute your VM's public IP (or your own DNS name) and keep the /files/ path. The server does not care which you use - it builds each upload URL from whatever host your client connected to.

Step 6 - Confirm the health endpoint

nginx serves an unauthenticated health endpoint for load balancers and probes:

curl -s http://127.0.0.1/healthz

It returns ok. This is the only path that never requires authentication, so it is safe for an Azure Load Balancer health probe without handing out a credential.

Step 7 - Confirm the authentication gate

Because a password is set on first boot, an unauthenticated request to the upload endpoint returns HTTP 401, so nobody can store files on your VM without the password. The following reads the per-VM password from the credentials file and proves the round-trip - anonymous is rejected, a wrong password is rejected, and the correct password creates an upload:

PW=$(sudo grep '^TUSD_PASSWORD=' /root/tusd-credentials.txt | cut -d= -f2-)
IP=$(hostname -I | awk '{print $1}')
echo "anonymous : $(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 11' http://$IP/files/)"
echo "wrong pw  : $(curl -s -o /dev/null -w '%{http_code}' -u admin:wrong-pw -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 11' http://$IP/files/)"
echo "authed    : $(curl -s -o /dev/null -w '%{http_code}' -u admin:$PW -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 11' http://$IP/files/)"

It prints anonymous : 401, wrong pw : 401 and authed : 201. The gate covers the whole protocol, not just creation: querying an upload's offset (HEAD), appending bytes (PATCH), downloading it (GET) and deleting it (DELETE) all require the same credential, so guessing an upload URL gains nothing.

The one exception is the credential-less OPTIONS preflight, which returns only the protocol capability headers (Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size). Browsers are required to send CORS preflights without credentials, so browser based tus clients could not work at all if it were blocked. It exposes no data and grants no ability to upload.

The tus upload endpoint returning HTTP 401 for an anonymous create and for a wrong password, HTTP 201 with a Location header for the per-VM password, HTTP 401 for anonymous HEAD and GET against that upload URL, and the unauthenticated /healthz probe returning 200 ok

Step 8 - Upload a file with the tus protocol

A tus upload is two steps: POST to create it and reserve its length, then PATCH to append bytes at an offset. The server replies to the create with a Location header - that URL is the upload.

PW=$(sudo grep '^TUSD_PASSWORD=' /root/tusd-credentials.txt | cut -d= -f2-)
IP=$(hostname -I | awk '{print $1}')
echo 'hello from tus' > /tmp/demo.txt
LEN=$(stat -c %s /tmp/demo.txt)
LOC=$(curl -s -D - -o /dev/null -u admin:$PW -X POST -H 'Tus-Resumable: 1.0.0' -H "Upload-Length: $LEN" http://$IP/files/ | tr -d '\r' | awk 'tolower($1)=="location:"{print $2}')
echo "upload URL: $LOC"
curl -s -o /dev/null -w 'PATCH -> %{http_code}\n' -u admin:$PW -X PATCH -H 'Tus-Resumable: 1.0.0' -H 'Upload-Offset: 0' -H 'Content-Type: application/offset+octet-stream' --data-binary @/tmp/demo.txt "$LOC"
curl -s -u admin:$PW "$LOC"

The PATCH returns 204 and the final curl prints the file back. Note that the Location is built from the host you connected to, so if you reach the VM through a DNS name or a load balancer on a non-default port, the upload URL points back at that same address and your client can follow it.

Step 9 - Resume an interrupted upload

This is the reason tus exists. Send part of a file, ask the server how much arrived, then continue from that offset rather than starting over:

PW=$(sudo grep '^TUSD_PASSWORD=' /root/tusd-credentials.txt | cut -d= -f2-)
IP=$(hostname -I | awk '{print $1}')
cd /tmp && dd if=/dev/urandom of=big.bin bs=1M count=3 status=none
LEN=$(stat -c %s big.bin); head -c 1048576 big.bin > part1.bin; tail -c $((LEN-1048576)) big.bin > part2.bin
LOC=$(curl -s -D - -o /dev/null -u admin:$PW -X POST -H 'Tus-Resumable: 1.0.0' -H "Upload-Length: $LEN" http://$IP/files/ | tr -d '\r' | awk 'tolower($1)=="location:"{print $2}')
curl -s -o /dev/null -w 'first chunk  -> %{http_code}\n' -u admin:$PW -X PATCH -H 'Tus-Resumable: 1.0.0' -H 'Upload-Offset: 0' -H 'Content-Type: application/offset+octet-stream' --data-binary @part1.bin "$LOC"
OFF=$(curl -s -D - -o /dev/null -u admin:$PW -I -H 'Tus-Resumable: 1.0.0' "$LOC" | tr -d '\r' | awk 'tolower($1)=="upload-offset:"{print $2}')
echo "server has $OFF of $LEN bytes - resuming from there"
curl -s -o /dev/null -w 'resumed      -> %{http_code}\n' -u admin:$PW -X PATCH -H 'Tus-Resumable: 1.0.0' -H "Upload-Offset: $OFF" -H 'Content-Type: application/offset+octet-stream' --data-binary @part2.bin "$LOC"
curl -s -u admin:$PW -o back.bin "$LOC"
sha256sum big.bin back.bin
curl -s -o /dev/null -u admin:$PW -X DELETE -H 'Tus-Resumable: 1.0.0' "$LOC"
rm -f big.bin part1.bin part2.bin back.bin

The HEAD reports Upload-Offset: 1048576, the resumed PATCH returns 204, and the two sha256sum values match - the reassembled file is byte identical to the original. A real client does this automatically: it stores the upload URL, and on any failure it issues the same HEAD and continues. The final DELETE is the termination extension, which removes the upload and frees its space.

A 3145728-byte upload created, interrupted after 1 MiB, the server reporting Upload-Offset 1048576, the upload resumed from that byte offset without restarting, and matching sha256 checksums proving the reassembled file is byte identical

Step 10 - Review the security posture and upload limits

sudo stat -c 'file: %n  perms: %a  owner: %U:%G' /root/tusd-credentials.txt /etc/nginx/.tusd.htpasswd
ps -o user= -C tusd | head -1
IP=$(hostname -I | awk '{print $1}')
curl -s -D - -o /dev/null -X OPTIONS -H 'Tus-Resumable: 1.0.0' http://$IP/files/ | tr -d '\r' | grep -iE '^tus-(max-size|extension|version):'

The credentials file is 600 root:root and the bcrypt hash file is 640 root:www-data, so the password is readable only by root and the nginx worker. tusd runs as the unprivileged tusd user, and the OPTIONS response shows the advertised Tus-Max-Size of 5368709120 bytes (5 GiB) - a client is told the limit up front and an oversized upload is rejected at creation rather than after wasting bandwidth.

The per-VM credentials file at mode 600 root:root with the password masked, the bcrypt password hash file at mode 640 root:www-data, tusd running as the unprivileged tusd user, the 5 GiB per-upload cap, the advertised tus extensions, and uploads stored on the dedicated data disk

Step 11 - Connect a real tus client

Any tus 1.0.0 client works - point it at http://<vm-public-ip>/files/ and supply the admin Basic Auth credential. In the browser with tus-js-client or Uppy:

const upload = new tus.Upload(file, {
  endpoint: "http://<vm-public-ip>/files/",
  headers: { Authorization: "Basic " + btoa("admin:<your-password>") },
  retryDelays: [0, 3000, 5000, 10000, 20000],
  onError:    (err)               => console.error(err),
  onProgress: (sent, total)       => console.log((sent / total * 100).toFixed(1) + "%"),
  onSuccess:  ()                  => console.log("done:", upload.url),
});
upload.start();

Or server side with tus-py-client:

pip install tuspy

import base64, tusclient.client
auth = base64.b64encode(b"admin:<your-password>").decode()
client = tusclient.client.TusClient("http://<vm-public-ip>/files/",
                                    headers={"Authorization": f"Basic {auth}"})
client.uploader("bigfile.iso", chunk_size=8 * 1024 * 1024).upload()

retryDelays is what turns a dropped connection into an automatic resume rather than a failed upload. Never embed the password in a page you serve to untrusted users - see Maintenance for the recommended production shape.

Maintenance

  • Password: the endpoint password is set on first boot and stored as a bcrypt entry in /etc/nginx/.tusd.htpasswd. To change it, run sudo htpasswd -B /etc/nginx/.tusd.htpasswd admin and then sudo systemctl reload nginx. To add a second client credential, use the same command with a different username.
  • Upload size cap: the 5 GiB per-upload limit is the -max-size flag in /etc/systemd/system/tusd.service. To change it, edit that value, then sudo systemctl daemon-reload && sudo systemctl restart tusd. Keep a finite cap - 0 means unlimited and lets a single upload fill the disk.
  • Storage: every uploaded file and its .info metadata sidecar lives under /var/lib/tusd/uploads on the dedicated data disk. Back up that volume, and grow it in the Azure portal as your workload requires. tusd does not expire uploads on its own, so schedule your own retention job (for example a find /var/lib/tusd/uploads -mtime +30 -delete cron) if clients do not DELETE completed uploads.
  • Restrict access: the appliance serves plain HTTP on port 80. For production, restrict port 80 to trusted IP ranges in your Network Security Group, and front it with TLS (for example certbot with your own domain) terminating on :443. Basic Auth over plain HTTP sends the credential in a reversible encoding on every request, so TLS is strongly recommended for any deployment beyond a private network.
  • Production auth: for multi-tenant or public-facing use, prefer per-user tokens over one shared password. tusd supports pre-create hooks (-hooks-dir or -hooks-http) that can validate a JWT or session token supplied by your own application before an upload is allowed. Add the flag in /etc/systemd/system/tusd.service; the nginx Basic Auth gate can then be relaxed for the paths your hook protects.
  • Loopback binding: tusd is bound to 127.0.0.1:8080 in /etc/systemd/system/tusd.service, so nginx is the only path in. Keep it that way - changing -host to a public interface would expose an unauthenticated upload endpoint to the internet.
  • Behind a proxy or load balancer: tusd runs with -behind-proxy and nginx forwards the verbatim Host header, so the Location it returns points back at whatever address the client used, including a non-default port. If you add your own proxy in front, forward Host, X-Forwarded-Proto and X-Forwarded-Host the same way or clients will be handed upload URLs they cannot follow.
  • Logs: sudo journalctl -u tusd -f shows every request, upload creation and completion.
  • Security patches: unattended-upgrades remains enabled so the OS continues to receive security updates automatically.

Support

cloudimg provides 24/7 expert support for this image. Contact support@cloudimg.co.uk.