Deployment guide

tusd

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 must restart from zero whenever it fails, a tus client creates an upload and then appends bytes to it. If the connection drops, such as on a flaky mobile network, a closed laptop lid or a proxy timeout, the client asks the server how many bytes 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.

This SecureAnyCloud image installs the pinned tusd 2.10.0 single Go binary, running under systemd with its local filesystem storage backend. There is no external database to operate.

The image is 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. 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 is the gate. The user is admin, with a unique password created on the first boot of your VM. The gate applies to every tus method: creating an upload, querying its offset, appending bytes, downloading it and deleting it. Each upload is size-capped, and uploads are stored on a dedicated Azure data disk mounted at /var/lib/tusd.

What you get:

  • 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 created 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 and nginx.service as systemd units, enabled and active
  • An unauthenticated /healthz endpoint for Azure Load Balancer health probes
  • 24/7 SecureAnyCloud support

Prerequisites

You need an active Azure subscription, an SSH key pair, and a VNet and 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 network and disk bound upload throughput.

For NSG inbound rules, allow 22/tcp from your management network and 80/tcp for the tus endpoint. Allow 443/tcp if you add TLS. The appliance serves plain HTTP on port 80. In production, 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 and search the Marketplace for tusd by SecureAnyCloud. Select Create.

On the Basics tab, 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. Your uploads are stored there, so size it for your workload. Then choose Review + create and Create.

Step 2 - Deploy from the Azure CLI

# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:tusd:default:latest
az vm create \
  --resource-group <your-rg> \
  --name tusd \
  --image sac1647283583153:tusd:default:latest \
  --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 to loopback only, nginx is the sole path in, and nothing reaches tusd without passing the auth gate first. Your uploads live on the dedicated Azure 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. On the first boot of your VM, a unique password is created 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 builds each upload URL from whatever host your client connected to, so either address works.

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 an Azure Load Balancer health probe can use it without a credential.

Step 7 - Confirm the authentication gate

A password is set on first boot, so an unauthenticated request to the upload endpoint returns HTTP 401. Nobody can store files on your VM without the password.

The following commands read the per-VM password from the credentials file and prove the round trip. An anonymous request 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. It returns only the protocol capability headers (Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size). Browsers must 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.

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 request with a Location header, and 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.

The Location is built from the host you connected to. 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

Resuming is the reason tus exists. Send part of a file and 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, so 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.

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 only root and the nginx worker can read the password. tusd runs as the unprivileged tusd user.

The OPTIONS response shows the advertised Tus-Max-Size of 5368709120 bytes (5 GiB). A client is told the limit up front, and the server rejects an oversized upload at creation rather than after wasting bandwidth.

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 on the 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 setup.

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, then run 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 run sudo systemctl daemon-reload && sudo systemctl restart tusd. Keep a finite cap, because 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. If clients do not DELETE completed uploads, schedule your own retention job, for example a find /var/lib/tusd/uploads -mtime +30 -delete cron.
  • Restrict access: the appliance serves plain HTTP on port 80. In production, restrict port 80 to trusted IP ranges in your Network Security Group. 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 use TLS 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. You can then relax the nginx Basic Auth gate 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 keeps receiving security updates automatically.

Support

SecureAnyCloud provides 24/7 expert support for this image. Email sales@secureanycloud.com.

Deploy on Azure

Launch tusd Resumable Upload 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

Deploy on Azure