Deployment guide

tusd Resumable Upload Server 2.10.1

Overview

This guide covers launching and using the tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud file sharing and sync listing on AWS Marketplace. You pick it as the software version when you subscribe and launch.

tusd is the official reference server for tus, an open protocol for resumable file uploads over HTTP (tus 1.0.0). A tus client creates an upload, then appends bytes to it with PATCH requests. If the connection drops (a mobile network, a closed laptop lid, a proxy timeout), the client asks the server with HEAD how many bytes arrived. It then continues from exactly that offset instead of starting again.

That makes tus a good fit for large media, backups, datasets and uploads from unreliable networks. Client libraries exist for browsers (tus-js-client, Uppy), Python (tus-py-client), Go, Java, .NET, iOS and Android.

On this image the tus endpoint is https://<host>/files/. It is served over HTTPS only, and port 80 only redirects to it.

What you get:

  • A password unique to your instance. tusd has no accounts of its own, so nginx puts HTTP Basic Auth (user admin) in front of every tus method: create, offset query, append, download and delete. The password is generated on your instance's first boot and written to /root/tusd-credentials.txt (root only). Nothing secret is in the image.
  • The password is checked before any upload byte is read. nginx refuses an unauthenticated or wrong-password client before it reads the request body, so such a client cannot fill the disk or tie up the server.
  • HTTPS from the first boot. nginx serves the endpoint on port 443 with a certificate generated on your instance. A certificate you install yourself is never replaced (Step 10).
  • tusd has no network access. tusd listens on a unix socket that only nginx can open and runs as the unprivileged tusd account. Its systemd sandbox allows it no IP traffic at all. Hooks, cloud storage back ends and the metrics endpoint are off. Neither tusd nor nginx can reach the EC2 instance metadata service.
  • Downloads cannot run in your site. Files people upload are served with a sandbox content security policy and nosniff. Anything that could be active (HTML, SVG, XML, JavaScript, unknown types) downloads as an attachment.
  • Built from source with current fixes. tusd 2.10.1 is compiled from the release's tagged source with Go 1.27.3 and fixed releases of three libraries. Upstream's release binary carried advisories that a newer toolchain fixes (see Build provenance and license).
  • Your data on its own volume. Uploads live on a dedicated 32 GiB data volume at /var/lib/tusd that you can grow and snapshot on its own. Incomplete uploads are cleaned up after 7 days. Completed uploads are kept until you delete them.
  • A fully patched Ubuntu 24.04 LTS base at build time, with unattended security upgrades switched on.
  • The software on the image: tusd 2.10.1 (MIT license), nginx from Ubuntu 24.04, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
  • Default user ubuntu, architecture x86_64, and recommended instance type m5.large.

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet.
  • A security group that allows TCP 22 (SSH) from your own network. It must also allow TCP 443 (the tus endpoint) and TCP 80 (redirects to HTTPS) from the networks your upload clients use. Open 443 only as widely as your clients need.
1

Launch the instance

In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04 as the software version, pick your Region, and choose Launch through EC2. Choose m5.large, your key pair, a security group as above, and a subnet.

The AMI has two volumes: the 8 GiB root volume (/dev/sda1) and a 32 GiB data volume (/dev/sdf, mounted at /var/lib/tusd) that holds every upload. If you expect large uploads, raise the data volume under Configure storage before you launch. Step 11 shows how to grow it later.

To launch with the AWS CLI, replace the values in angle brackets. This command sets the data volume to 200 GiB:

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --block-device-mappings 'DeviceName=/dev/sdf,Ebs={VolumeSize=200,VolumeType=gp3}' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=tusd}]'
2

Connecting to your instance

Connect as the ubuntu user, which is the SSH user for tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04:

ssh -i <your-key.pem> ubuntu@<public-ip>
3

Check the services

First boot works out the instance's name, generates the certificate and the password, and starts tusd. It proves a full upload round trip over HTTPS and only then opens the web server. This takes well under a minute:

systemctl is-active tusd nginx tusd-set-host tusd-expire.timer
active active active active

tusd-set-host runs on every boot before nginx: it keeps the served name, the certificate and nginx's host rules in step (Step 10). tusd-expire.timer removes abandoned incomplete uploads (Step 8).

Only SSH (22) and nginx (80, 443) listen on the network. The :53 entries are Ubuntu's local DNS resolver. :323 is the time service on the loopback address, and :68 is the DHCP client. tusd has no TCP or UDP socket at all:

sudo ss -lntup | awk 'NR>1 {print $1, $5}' | sort -u
tcp 0.0.0.0:22 tcp 0.0.0.0:443 tcp 0.0.0.0:80 tcp 127.0.0.53%lo:53 tcp 127.0.0.54:53 tcp [::]:22 tcp [::]:443 tcp [::]:80 udp 127.0.0.1:323 udp 127.0.0.53%lo:53 udp 127.0.0.54:53 udp 172.31.16.10%ens5:68 udp [::1]:323

tusd serves a unix socket that only nginx (group www-data) can open, and runs as the tusd account with hooks, cloud stores, metrics and profiling switched off:

sudo ls -l /run/tusd/ ps -o user=,args= -C tusd tusd -version
total 0 srwxrwx--- 1 tusd www-data 0 Oct 31 11:55 tusd.sock tusd /usr/local/bin/tusd -unix-sock /run/tusd/tusd.sock -base-path /files/ -upload-dir /var/lib/tusd/uploads -behind-proxy -max-size 5368709120 -cors-allow-origin ^sac-cors-closed$ -cors-allow-credentials=false -expose-metrics=false -expose-pprof=false -show-greeting=false -enable-experimental-protocol=false -network-timeout 60s Version: v2.10.1 Commit: 388a27cb81c33b2349e0308b5e97e23797e86fd9 Date: sac source rebuild go1.27.3

If nginx is not running, first boot has either not finished or failed. In that case the endpoint is never opened. Check systemctl status tusd-firstboot and sudo tail -40 /var/log/sac/tusd-firstboot.log.

4

Get your endpoint and password

sudo cat /root/tusd-credentials.txt

The file is readable by root only and holds the endpoint, this instance's Basic Auth user and password, the per-upload size cap and the certificate fingerprint:

# tusd Resumable Upload Server - generated on first boot. Unique to this instance; keep it safe.
# Point any tus 1.0.0 client (tus-js-client, Uppy, tus-py-client, tusd's Go client, ...) at TUSD_ENDPOINT with HTTP # Basic Auth (TUSD_USERNAME / TUSD_PASSWORD). Every method but OPTIONS needs it. Compare TUSD_TLS_SHA256 with the # certificate your client sees (or install your own certificate: see the guide). TUSD_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com/ TUSD_ENDPOINT=https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ TUSD_USERNAME=admin TUSD_PASSWORD=<TUSD_PASSWORD> TUSD_MAX_UPLOAD_BYTES=5368709120 TUSD_TLS_SHA256=68:84:96:05:B5:CB:B0:4C:A0:23:ED:B0:05:A8:2F:CB:F3:E5:75:E4:27:68:31:85:23:CD:05:63:E7:FB:DE:92

To see everything but the password:

sudo grep -E '^TUSD_(URL|ENDPOINT|USERNAME|MAX_UPLOAD_BYTES|TLS_SHA256)=' /root/tusd-credentials.txt
TUSD_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com/ TUSD_ENDPOINT=https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ TUSD_USERNAME=admin TUSD_MAX_UPLOAD_BYTES=5368709120 TUSD_TLS_SHA256=68:84:96:05:B5:CB:B0:4C:A0:23:ED:B0:05:A8:2F:CB:F3:E5:75:E4:27:68:31:85:23:CD:05:63:E7:FB:DE:92

Anyone may ask the endpoint what it supports. This is an unauthenticated OPTIONS request, which tus clients and browsers send first. The endpoint answers with tus headers only:

curl -s --cacert /etc/ssl/tusd/tusd.crt -i -X OPTIONS -H 'Tus-Resumable: 1.0.0' \
  "$(sudo sed -n 's/^TUSD_ENDPOINT=//p' /root/tusd-credentials.txt)" | tr -d '\r' | grep -iE '^(HTTP|tus-)'
HTTP/2 200 tus-extension: creation,creation-with-upload,termination,concatenation,creation-defer-length tus-max-size: 5368709120 tus-resumable: 1.0.0 tus-version: 1.0.0

You can confirm the whole path from the instance itself. The helper creates, appends to, queries, downloads and deletes a small upload over HTTPS through nginx, with the certificate pinned. It also checks that requests without the password, with a wrong password or from a foreign web origin are refused:

sudo /usr/local/sbin/tusd-verify.sh
TUSD_ROUNDTRIP_OK (create/append/offset/download/terminate over HTTPS; unauth + wrong password + foreign origin refused; files sandboxed)

Change the password with tusd-set-password. With no argument, it generates a new 24-character password. With -, it reads yours from standard input (at least 16 printable characters, no spaces, :, " or backslash).

The command never accepts the password as an argument, so it does not appear in the process list or your shell history. It updates /root/tusd-credentials.txt, and nginx refuses the old password as soon as the command returns:

$
sudo tusd-set-password tusd-set-password: password replaced (sudo cat /root/tusd-credentials.txt)

To choose your own:

$ read -rs NEWPW && printf '%s\n' "$NEWPW" | sudo tusd-set-password - ; unset NEWPW tusd-set-password: password replaced (sudo cat /root/tusd-credentials.txt)
5

Upload, interrupt and resume with curl

This step drives the tus protocol by hand with curl, so you can see exactly what a tus client does. It works on the instance itself. From your own machine, copy the endpoint and the certificate as shown in Step 6.

First, create a private work directory in memory (/dev/shm), a copy of the instance's certificate, and a curl configuration file that holds the password. The password is then never on a command line or in your shell history:

install -d -m 0700 /dev/shm/tus-demo && cd /dev/shm/tus-demo
sudo sed -n 's/^TUSD_ENDPOINT=//p' /root/tusd-credentials.txt > endpoint cp /etc/ssl/tusd/tusd.crt server.crt ( umask 077
sudo sed -n 's/^TUSD_PASSWORD=\(.*\)$/user = "admin:\1"/p' /root/tusd-credentials.txt > tus.cfg printf 'cacert = "server.crt"\nheader = "Tus-Resumable: 1.0.0"\nsilent\nshow-error\n' >> tus.cfg ) head -c 3145728 /dev/urandom > sample.bin ls -l tus.cfg sample.bin | awk '{print $1, $5, $9}'
-rw-r--r-- 3145728 sample.bin -rw------- 112 tus.cfg

Create the upload. A POST to the endpoint declares the size and, optionally, base64-encoded metadata. The answer is 201 Created, with the new upload's URL in Location:

cd /dev/shm/tus-demo curl -K tus.cfg -X POST -H 'Upload-Length: 3145728' \
  -H "Upload-Metadata: filename $(printf sample.bin | base64),filetype $(printf application/octet-stream | base64)" \
  -D created.hdr -o /dev/null "$(cat endpoint)" tr -d '\r' < created.hdr | grep -iE '^(HTTP|location|tus-resumable)' tr -d '\r' < created.hdr | sed -n 's/^location: //Ip' > upload-url
HTTP/2 201 location: https://ec2-203-0-113-10.compute-1.amazonaws.com/files/4c0b4fe05043d17a0f098e6565c8c708 tus-resumable: 1.0.0

Append the first 1 MiB. Each PATCH names the offset it starts at. The server answers 204 with the new offset:

cd /dev/shm/tus-demo head -c 1048576 sample.bin > chunk1 curl -K tus.cfg -X PATCH -H 'Content-Type: application/offset+octet-stream' -H 'Upload-Offset: 0' \
  --data-binary @chunk1 -D - -o /dev/null "$(cat upload-url)" | tr -d '\r' | grep -iE '^(HTTP|upload-offset)'
HTTP/2 204 upload-offset: 1048576

Lose the connection partway. This sends the remaining 2 MiB slowly and gives up after 3 seconds, as a dropped connection would:

cd /dev/shm/tus-demo tail -c +1048577 sample.bin > rest curl -K tus.cfg -X PATCH -H 'Content-Type: application/offset+octet-stream' -H 'Upload-Offset: 1048576' \
  --data-binary @rest --limit-rate 200k --max-time 3 -o /dev/null "$(cat upload-url)" \
  || echo "curl exit $?: the transfer was cut off part way"
curl: (28) Operation timed out after 3000 milliseconds with 0 bytes received curl exit 28: the transfer was cut off part way

Ask the server how much arrived. A HEAD on the upload URL returns the offset the server has stored. The bytes that arrived before the connection dropped are kept:

cd /dev/shm/tus-demo curl -K tus.cfg -I "$(cat upload-url)" | tr -d '\r' | grep -iE '^(HTTP|upload-offset|upload-length)'
HTTP/2 200 upload-length: 3145728 upload-offset: 1703936

Resume from that offset and send only the rest:

cd /dev/shm/tus-demo OFF="$(curl -K tus.cfg -I "$(cat upload-url)" | tr -d '\r' | sed -n 's/^upload-offset: //Ip')" echo "resuming at byte ${OFF}" tail -c +"$((OFF + 1))" sample.bin > remainder curl -K tus.cfg -X PATCH -H 'Content-Type: application/offset+octet-stream' -H "Upload-Offset: ${OFF}" \
  --data-binary @remainder -D - -o /dev/null "$(cat upload-url)" | tr -d '\r' | grep -iE '^(HTTP|upload-offset)'
resuming at byte 1703936 HTTP/2 204 upload-offset: 3145728

Download it and compare. A GET on the upload URL returns the file. Both checksums match:

cd /dev/shm/tus-demo curl -K tus.cfg -D - -o downloaded.bin "$(cat upload-url)" | tr -d '\r' \
  | grep -iE '^(HTTP|content-length|content-disposition|content-security-policy|x-content-type-options)' sha256sum sample.bin downloaded.bin
HTTP/2 200 content-length: 3145728 content-disposition: attachment;filename="sample.bin" content-security-policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none' x-content-type-options: nosniff 3cf931387d7bb96781d5964a5c9c4c1967ac1a7390b3347efad9fd97677819d4 sample.bin 3cf931387d7bb96781d5964a5c9c4c1967ac1a7390b3347efad9fd97677819d4 downloaded.bin

Delete it (the tus termination extension). The upload is gone afterwards:

cd /dev/shm/tus-demo curl -K tus.cfg -X DELETE -o /dev/null -w 'DELETE: HTTP %{http_code}\n' "$(cat upload-url)" curl -K tus.cfg -I -o /dev/null -w 'HEAD after delete: HTTP %{http_code}\n' "$(cat upload-url)"
DELETE: HTTP 204 HEAD after delete: HTTP 404

What the server refuses. No credential and a wrong password both get 401. An unauthenticated client is refused before its body is read, so the 64 MiB upload below is answered at once with almost nothing sent.

An upload larger than the cap gets 413. tusd sends it when the declared Upload-Length is too large. nginx sends it, before reading anything, when a request body is declared larger than the cap. A method override on anything but an authenticated POST gets 400, and port 80 only redirects:

cd /dev/shm/tus-demo EP="$(cat endpoint)" curl -sS --cacert server.crt -o /dev/null -w 'no credential: HTTP %{http_code}\n' \
  -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 4' "$EP" ( umask 077; printf 'user = "admin:not-the-password"\n' > wrong.cfg ) curl -sS --cacert server.crt -K wrong.cfg -o /dev/null -w 'wrong password: HTTP %{http_code}\n' \
  -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 4' "$EP" head -c 67108864 /dev/zero > big.bin curl -sS --cacert server.crt -o /dev/null -w 'no credential, 64 MiB body: HTTP %{http_code}, %{size_upload} bytes sent\n' \
  -X POST -H 'Tus-Resumable: 1.0.0' -H 'Upload-Length: 67108864' \
  -H 'Content-Type: application/offset+octet-stream' --data-binary @big.bin "$EP" curl -K tus.cfg -o /dev/null -w 'Upload-Length above the cap: HTTP %{http_code}\n' \
  -X POST -H 'Upload-Length: 5368709121' "$EP" curl -K tus.cfg --http1.1 -o /dev/null -w 'request body declared above the cap: HTTP %{http_code}\n' \
  -X POST -H 'Upload-Length: 4' -H 'Content-Type: application/offset+octet-stream' -H 'Content-Length: 6442450944' "$EP" curl -sS --cacert server.crt -o /dev/null -w 'anonymous OPTIONS with X-HTTP-Method-Override: HTTP %{http_code}\n' \
  -X OPTIONS -H 'X-HTTP-Method-Override: DELETE' "$EP" curl -K tus.cfg -o /dev/null -w 'authenticated GET with X-HTTP-Method-Override: HTTP %{http_code}\n' \
  -H 'X-HTTP-Method-Override: DELETE' "$EP" curl -sS -o /dev/null -w 'port 80: HTTP %{http_code} (a redirect to https)\n' "http://$(echo "$EP" | cut -d/ -f3)/files/"
no credential: HTTP 401 wrong password: HTTP 401 no credential, 64 MiB body: HTTP 401, 65536 bytes sent Upload-Length above the cap: HTTP 413 request body declared above the cap: HTTP 413 anonymous OPTIONS with X-HTTP-Method-Override: HTTP 400 authenticated GET with X-HTTP-Method-Override: HTTP 400 port 80: HTTP 301 (a redirect to https)

Files that could run in a browser download as attachments. An SVG with a script in it, uploaded in one request (the creation-with-upload extension), comes back as an attachment under a sandbox policy:

cd /dev/shm/tus-demo printf '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>' > x.svg curl -K tus.cfg -X POST -H "Upload-Length: $(wc -c < x.svg)" \
  -H "Upload-Metadata: filename $(printf x.svg | base64),filetype $(printf image/svg+xml | base64)" \
  -H 'Content-Type: application/offset+octet-stream' --data-binary @x.svg -D svg.hdr -o /dev/null "$(cat endpoint)" SVG="$(tr -d '\r' < svg.hdr | sed -n 's/^location: //Ip')" curl -K tus.cfg -D - -o /dev/null "$SVG" | tr -d '\r' \
  | grep -iE '^(HTTP|content-type|content-disposition|content-security-policy|x-content-type-options)' curl -K tus.cfg -X DELETE -o /dev/null -w 'DELETE: HTTP %{http_code}\n' "$SVG"
HTTP/2 200 content-type: image/svg+xml content-disposition: attachment;filename="x.svg" content-security-policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none' x-content-type-options: nosniff DELETE: HTTP 204

Clean up. The curl configuration holds your password, so remove it when you are done:

shred -u /dev/shm/tus-demo/tus.cfg && rm -rf /dev/shm/tus-demo
sudo find /var/lib/tusd/uploads -mindepth 1 | wc -l
0
6

Use a tus client

Any tus 1.0.0 client works. Point it at the endpoint, give it the Basic Auth header, and trust this instance's certificate (or install your own, Step 10). Do not switch off certificate checks.

On your own machine, fetch the certificate and the password over SSH. Keep the password in a file that only you can read. Compare the certificate's fingerprint with TUSD_TLS_SHA256 from Step 4:

scp -i <your-key.pem> ubuntu@<public-ip>:/etc/ssl/tusd/tusd.crt ./tusd.crt ( umask 077; ssh -i <your-key.pem> ubuntu@<public-ip> \
  "sudo sed -n 's/^TUSD_PASSWORD=//p' /root/tusd-credentials.txt" > ~/.tusd-password ) openssl x509 -in tusd.crt -noout -fingerprint -sha256

Python (tus-py-client). Install it with pip install tuspy. This script uploads a file in 1 MiB chunks and stops partway, as an interrupted client would. It then resumes in a fresh uploader from the offset the server reports, downloads the result, compares it, and deletes it:

#!/usr/bin/env python3 """Upload a file to your tusd endpoint with tus-py-client: stop part way, resume from the server's offset, verify, delete.""" import base64 import hashlib import os import sys import requests from tusclient import client endpoint = os.environ["TUSD_ENDPOINT"]
# https://<your-host>/files/ os.environ["REQUESTS_CA_BUNDLE"] = os.environ["TUSD_CA"]
# the instance's certificate (tusd.crt) with open(os.environ["TUSD_PASSWORD_FILE"]) as fh: # a 0600 file holding only the password password = fh.read().strip() auth = {"Authorization": "Basic " + base64.b64encode(f"admin:{password}".encode()).decode()} path = sys.argv[1] MiB = 1024 * 1024 tus = client.TusClient(endpoint, headers=auth) first = tus.uploader(path, chunk_size=1 * MiB, metadata={"filename": os.path.basename(path)}) first.upload(stop_at=3 * MiB) # stop part way, as a dropped connection would print(f"created {first.url.rsplit('/', 1)[1]}; stopped at offset {first.offset} of {os.path.getsize(path)}") # Later, in another process: a new uploader for the same URL asks the server for its offset and continues from there. second = tus.uploader(path, url=first.url, chunk_size=1 * MiB) print(f"server reports offset {second.offset}; resuming") second.upload() print(f"upload complete at offset {second.offset}") headers = {**auth, "Tus-Resumable": "1.0.0"} got = requests.get(first.url, headers=headers, timeout=60) with open(path, "rb") as fh: same = hashlib.sha256(got.content).hexdigest() == hashlib.sha256(fh.read()).hexdigest() print(f"download: HTTP {got.status_code}, {len(got.content)} bytes, sha256 matches the local file: {same}") print(f"delete: HTTP {requests.delete(first.url, headers=headers, timeout=60).status_code}") print(f"after delete: HTTP {requests.head(first.url, headers=headers, timeout=60).status_code}")

Run against this instance from a laptop with an 8 MiB test file:

$ head -c 8388608 /dev/urandom > sample-8mib.bin $ TUSD_ENDPOINT=https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ TUSD_CA=./tusd.crt \
  TUSD_PASSWORD_FILE=~/.tusd-password python3 tus_upload.py sample-8mib.bin created cbb943f878e16ac8eee6ce2bafe50667; stopped at offset 3145728 of 8388608 server reports offset 3145728; resuming upload complete at offset 8388608 download: HTTP 200, 8388608 bytes, sha256 matches the local file: True delete: HTTP 204 after delete: HTTP 404

In a real application, give the uploader store_url=True with a url_storage (for example tusclient.storage.filestorage.FileStorage), so a restarted process finds its upload URL again. Also set retries, so the uploader retries by itself.

JavaScript (tus-js-client, Uppy). Pass the endpoint and the header to the upload; chunkSize is optional (Step 7):

const upload = new tus.Upload(file, { endpoint: 'https://<your-host>/files/', headers: { Authorization: 'Basic ' + btoa('admin:' + password) }, chunkSize: 50 * 1024 * 1024, retryDelays: [0, 3000, 10000, 30000], metadata: { filename: file.name, filetype: file.type }, }) upload.start()

From Node.js, make the instance's certificate trusted for the process with NODE_EXTRA_CA_CERTS=./tusd.crt. In a browser on another site, you must open CORS first (Step 9). Read the warning there before you put the password into a web page.

7

Limits

Upload size. Each upload may be at most TUSD_MAX_UPLOAD_BYTES (5 GiB by default). tusd advertises it as Tus-Max-Size and answers 413 to a larger Upload-Length.

Request size and chunk size. nginx's cap on a single request body is kept equal to the upload cap, and request bodies are streamed to tusd, not buffered. So one PATCH may carry up to 5 GiB, and every tus client's default works. That includes tus-js-client, which sends the whole remaining file in one request unless you set chunkSize.

If you set a chunkSize, keep it at or below the upload cap. nginx refuses a larger request body with 413 before reading it. Resuming does not depend on the chunk size, because the bytes of an interrupted request that arrived are kept (Step 5). A request that sends no data for 60 seconds is closed.

Raising or lowering the cap. tusd-configure changes tusd's cap and nginx's request cap together. It restarts tusd, reloads nginx and updates the credentials file:

sudo tusd-configure --help | grep '^#'
# Usage:
sudo tusd-configure [--max-size BYTES] [--cors-origin https://app.example.com[,https://other.example]|none]
# [--expire-days N]
# --max-size per-upload cap (tusd -max-size) and nginx's per-request body cap, kept equal # --cors-origin browser origins allowed to call the endpoint from JavaScript (exact https origins), or none. # Access-Control-Allow-Credentials is never sent: a browser client must send the Authorization header # itself (tus-js-client / Uppy 'headers'), so a page on another site can never use a browser's saved
# password. Opening CORS exposes the password to whoever runs that page: prefer issuing uploads from
# your own backend.
# --expire-days incomplete uploads untouched this many days are deleted daily (0 = never)

For example, to allow 10 GiB uploads (make sure the data volume has room, Step 11):

$
sudo tusd-configure --max-size 10737418240 tusd-configure: max-size 10737418240 bytes; CORS origins: none; expire-days 7

Rate limits. Each client address may make 50 requests a second to /files/ with a burst of 100, and hold 16 connections at once. Beyond that, nginx answers 429 Too Many Requests before checking the password, and tus clients retry. Here are 300 requests fired at once from one address:

EP="$(sudo sed -n 's/^TUSD_ENDPOINT=//p' /root/tusd-credentials.txt)" seq 1 300 | xargs -P 30 -I{} curl -s --cacert /etc/ssl/tusd/tusd.crt -o /dev/null -w '%{http_code}\n' \
  -X OPTIONS -H 'Tus-Resumable: 1.0.0' "$EP" | sort | uniq -c
256 200 44 429
8

Incomplete uploads expire, complete uploads are kept

tusd's file store never deletes anything by itself, so an upload that a client abandons would stay on the disk forever. tusd-expire.timer runs once a day, at a random time within an hour after midnight UTC, and at the next boot if the instance was off. It deletes incomplete uploads that have not received a byte for TUSD_EXPIRE_DAYS days (7 by default). An upload that is being written to is skipped.

The timer never deletes completed uploads. They stay until you delete them, with DELETE on the upload URL or by removing the files under /var/lib/tusd/uploads.

systemctl list-timers tusd-expire.timer --no-pager | head -2 grep '^TUSD_EXPIRE_DAYS=' /etc/sac/tusd.env
sudo systemctl start tusd-expire.service journalctl -u tusd-expire.service -o cat --no-pager | grep '^tusd-expire:' | tail -1
NEXT LEFT LAST PASSED UNIT ACTIVATES Sun 2026-11-01 00:51:05 UTC 12h - - tusd-expire.timer tusd-expire.service TUSD_EXPIRE_DAYS=7 tusd-expire: 0 incomplete upload(s) older than 7 day(s) removed

Change the period with sudo tusd-configure --expire-days 30, or switch expiry off with --expire-days 0. The job runs as the tusd account with no network access.

9

Browser clients on another site (CORS)

A web page served from another origin (for example https://app.example.com) may only call the endpoint if CORS allows that origin.

CORS is closed by default. tusd matches no origin. Before checking the password, nginx refuses any request, other than OPTIONS, GET and HEAD, that carries an Origin you have not allowed. Native clients (curl, tus-py-client, server-side code) send no Origin and are not affected.

Allow exact https origins, comma-separated:

$
sudo tusd-configure --cors-origin https://app.example.com tusd-configure: max-size 5368709120 bytes; CORS origins: https://app.example.com; expire-days 7

A preflight from that origin is now answered with its own name in Access-Control-Allow-Origin (never *). Another origin is still refused, even with the right password:

$ curl -sS -i --cacert /etc/ssl/tusd/tusd.crt -X OPTIONS -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization, tus-resumable, upload-length, upload-metadata' \
  https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ | tr -d '\r' | grep -iE '^(HTTP|access-control)' HTTP/2 200 access-control-allow-headers: Authorization, Origin, X-Requested-With, X-Request-ID, X-HTTP-Method-Override, Content-Type, Upload-Length, Upload-Offset, Tus-Resumable, Upload-Metadata, Upload-Defer-Length, Upload-Concat, Upload-Incomplete, Upload-Complete, Upload-Draft-Interop-Version access-control-allow-methods: POST, HEAD, PATCH, OPTIONS, GET, DELETE access-control-allow-origin: https://app.example.com access-control-max-age: 86400 $ curl -sS -i --cacert /etc/ssl/tusd/tusd.crt -X OPTIONS -H 'Origin: https://other.example' \
  -H 'Access-Control-Request-Method: POST' https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ | tr -d '\r' | grep -iE '^(HTTP|access-control)' HTTP/2 403 $ curl -K tus.cfg -o /dev/null -w '%{http_code}\n' -X POST -H 'Origin: https://other.example' \
  -H 'Upload-Length: 4' https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ 403 $ curl -K tus.cfg -o /dev/null -D post.hdr -w '%{http_code}\n' -X POST -H 'Origin: https://app.example.com' \
  -H 'Upload-Length: 4' https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ 201 $ tr -d '\r' < post.hdr | grep -iE '^access-control-(allow-origin|expose-headers|allow-credentials)' access-control-allow-origin: https://app.example.com access-control-expose-headers: Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Upload-Metadata, Upload-Defer-Length, Upload-Concat, Upload-Incomplete, Upload-Complete, Upload-Draft-Interop-Version

The upload that the last request created was deleted again. Access-Control-Allow-Credentials is never sent (the last command looked for it and found none), so a page on another site can never use a password that the browser has saved.

Your page has to send the Authorization header itself (the tus-js-client and Uppy headers option). This means everyone who can load that page can read the password. Open CORS only for pages used by people you would give the password to anyway. For public upload forms, keep CORS closed and let your own back end talk to tusd, or put an authenticating proxy in front.

Close CORS again with:

$
sudo tusd-configure --cors-origin none tusd-configure: max-size 5368709120 bytes; CORS origins: none; expire-days 7
10

Your own host name and certificate

On every boot, tusd-set-host works out the name to serve. It uses the name you set, or else the instance's public DNS name from the instance metadata service (IMDSv2). Every upload URL that tusd hands out is built from that configured name, never from the request:

systemctl is-enabled tusd-set-host.service
sudo tusd-set-hostname --boot
enabled tusd-set-hostname: unchanged (ec2-203-0-113-10.compute-1.amazonaws.com)

Use your own DNS name. Point a DNS record at the instance (an Elastic IP keeps the address fixed). Then run:

$
sudo tusd-set-hostname uploads.example.com tusd-set-hostname: the tus endpoint is https://uploads.example.com/files/ (certificate SHA-256 F3:B0:DC:F1:2F:C6:63:63:CF:B3:9F:DE:60:DE:58:84:DE:5D:7E:21:F3:9F:10:63:41:E7:0A:74:0B:93:AF:08)

The name is kept in /etc/sac/tusd-hostname for every later boot. The credentials file is updated, and nginx serves that name and redirects the instance's other addresses to it. The certificate in place was the one SecureAnyCloud generated, so it was re-created for the new name.

Which certificates are replaced. Only a certificate that SecureAnyCloud generated is ever re-created. Its SHA-256 fingerprint is recorded, root-owned, in /var/lib/sac/data/tusd-cert.sha256. A certificate is treated as SecureAnyCloud's only while it matches that record:

sudo cat /var/lib/sac/data/tusd-cert.sha256 openssl x509 -in /etc/ssl/tusd/tusd.crt -noout -fingerprint -sha256
68:84:96:05:B5:CB:B0:4C:A0:23:ED:B0:05:A8:2F:CB:F3:E5:75:E4:27:68:31:85:23:CD:05:63:E7:FB:DE:92 sha256 Fingerprint=68:84:96:05:B5:CB:B0:4C:A0:23:ED:B0:05:A8:2F:CB:F3:E5:75:E4:27:68:31:85:23:CD:05:63:E7:FB:DE:92

Install your own certificate (from your CA, or Let's Encrypt with a DNS challenge) by putting the full chain and the key at the same paths. Anything there that does not match the record is yours and is never replaced, not on boot and not when the name changes. That includes a symlink to a chain you manage elsewhere:

sudo install -m 0644 -o root -g root fullchain.pem /etc/ssl/tusd/tusd.crt
sudo install -m 0600 -o root -g root privkey.pem /etc/ssl/tusd/tusd.key
sudo nginx -t && sudo systemctl reload nginx

With a certificate installed that way for uploads.example.com, the boot unit and a name change leave it alone:

$ echo | openssl s_client -connect 127.0.0.1:443 -servername uploads.example.com 2>/dev/null \
  | openssl x509 -noout -subject -fingerprint -sha256 subject=CN = uploads.example.com sha256 Fingerprint=35:A6:24:90:65:63:AE:9B:1F:B1:63:FC:82:57:46:F6:21:25:EF:8C:38:FA:2D:5A:59:A3:2C:CD:2B:FE:28:9F $
sudo tusd-set-hostname --boot tusd-set-hostname: unchanged (uploads.example.com) $
sudo systemctl restart tusd-set-host.service && journalctl -u tusd-set-host.service -n 1 -o cat Finished tusd-set-host.service - tusd host name, certificate and nginx host rules for this boot (sac). $
sudo tusd-set-hostname ec2-203-0-113-10.compute-1.amazonaws.com tusd-set-hostname: the certificate in /etc/ssl/tusd is not one this helper minted: it is kept unchanged tusd-set-hostname: the tus endpoint is https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ (certificate SHA-256 35:A6:24:90:65:63:AE:9B:1F:B1:63:FC:82:57:46:F6:21:25:EF:8C:38:FA:2D:5A:59:A3:2C:CD:2B:FE:28:9F)

Afterwards, both files were byte for byte the ones installed. A real reboot does the same, because the boot unit is the same tusd-set-hostname --boot.

Go back to a generated certificate by removing both files and running the helper with the name to serve:

$
sudo rm /etc/ssl/tusd/tusd.crt /etc/ssl/tusd/tusd.key $
sudo tusd-set-hostname ec2-203-0-113-10.compute-1.amazonaws.com tusd-set-hostname: the tus endpoint is https://ec2-203-0-113-10.compute-1.amazonaws.com/files/ (certificate SHA-256 68:84:96:05:B5:CB:B0:4C:A0:23:ED:B0:05:A8:2F:CB:F3:E5:75:E4:27:68:31:85:23:CD:05:63:E7:FB:DE:92)

To return to the instance's own public name on every boot, run sudo rm /etc/sac/tusd-hostname && sudo tusd-set-hostname --boot.

11

Data volume, backups and growing it

Every upload (the data file and a small .info file with its size and metadata) is stored under /var/lib/tusd/uploads on the dedicated data volume, mounted by UUID:

df -h /var/lib/tusd | awk 'NR==1 || /tusd/'
sudo stat -c '%U:%G %a %n' /var/lib/tusd/uploads grep /var/lib/tusd /etc/fstab
Filesystem Size Used Avail Use% Mounted on /dev/nvme1n1 32G 32K 30G 1% /var/lib/tusd tusd:tusd 700 /var/lib/tusd/uploads UUID=13d7e808-5eb5-484d-a916-574e6b5ea21d /var/lib/tusd ext4 defaults,nofail 0 2

Backups. Take EBS snapshots of the data volume. Amazon Data Lifecycle Manager can schedule them. Uploads are plain files, so you can also copy completed ones elsewhere with rsync or the AWS CLI.

aws ec2 create-snapshot --volume-id <data-volume-id> --description "tusd uploads $(date -u +%F)"

Grow the volume without downtime. Raise its size in the EC2 console (Volumes, Modify volume) or with the CLI. Wait for the state to reach optimizing or completed, then grow the file system:

aws ec2 modify-volume --volume-id <data-volume-id> --size 500
sudo resize2fs "$(findmnt -no SOURCE /var/lib/tusd)" df -h /var/lib/tusd

Raise the upload cap (Step 7) only as far as the free space allows.

12

Updates

Unattended security upgrades update Ubuntu packages, nginx included. To update everything now, run sudo apt-get update && sudo apt-get upgrade. tusd itself is not an apt package. It is the binary that SecureAnyCloud built, at /usr/local/bin/tusd, and apt never changes it:

systemctl is-enabled unattended-upgrades apt-daily-upgrade.timer dpkg -S /usr/local/bin/tusd 2>&1 || true
enabled enabled dpkg-query: no path found matching pattern /usr/local/bin/tusd

SecureAnyCloud publishes a new version of this listing when tusd has a new release or a security fix. To move to it, launch the new version and copy your uploads across, or attach a snapshot of the data volume to it.

Security notes

  • Authentication before the body. nginx checks the Basic Auth credential in its access phase, before it reads a request body, and streams bodies to tusd without spooling them to disk. Step 5 shows an unauthenticated 64 MiB upload refused with almost nothing sent. OPTIONS (capability discovery and the browser preflight) is the only method without the password, and it returns tus headers only.
  • Method override. tus clients in environments without PATCH or DELETE may send POST with X-HTTP-Method-Override. The header is honored only on an authenticated POST. Any other request that carries it, above all an unauthenticated OPTIONS, is refused with 400 before it reaches tusd (Step 5).
  • Sandboxed downloads. Every response under /files/ carries Content-Security-Policy: sandbox, X-Content-Type-Options: nosniff, X-Frame-Options: DENY and HSTS. Only images, audio, video and plain text may display inline. HTML, SVG, XML, JavaScript and unknown types download as attachments (Step 5).
  • tusd isolated. tusd listens on a unix socket in /run/tusd (group www-data, so only nginx can open it). It runs as the unprivileged tusd account under a systemd sandbox (read-only system, private /tmp, no capabilities) and may use no IP at all. nginx sends it the configured host name and the real client address (X-Forwarded-For is set from the connection, never passed through), and strips the Authorization header.
  • Instance metadata. systemd denies tusd, nginx and the expiry job access to the EC2 instance metadata service (169.254.169.254 and its IPv6 address). Launch with IMDSv2 required (HttpTokens=required), as in Step 1.
  • Nothing else exposed. Hooks (HTTP, gRPC, file and plugin hooks), the S3, Google Cloud and Azure stores, the metrics and profiling endpoints and the greeting page are all off. /healthz (for load balancer checks, answered by nginx) is the only path outside /files/. Requests for host names that the instance does not serve are dropped. nginx refuses ambiguous request framing (request smuggling) before it reaches tusd.
  • Exact listeners. Only 22, 80 and 443 listen on the network (Step 3). Open them in the security group only to the networks that need them, and use the password over HTTPS only.

The following output shows the metadata block and the network sandbox as systemd applies them. tusd and the expiry job may use no IP address at all (0.0.0.0/0 ::/0, which includes the metadata addresses), and tusd may use only unix sockets. nginx has to serve the network, so it is denied only the metadata addresses:

systemctl show tusd nginx tusd-expire -p Id -p IPAddressDeny -p RestrictAddressFamilies
IPAddressDeny=0.0.0.0/0 ::/0 RestrictAddressFamilies=AF_UNIX Id=tusd.service IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32 RestrictAddressFamilies=~ Id=nginx.service IPAddressDeny=0.0.0.0/0 ::/0 RestrictAddressFamilies=~ Id=tusd-expire.service

Build provenance and license

tusd 2.10.1 was built from the source of the upstream v2.10.1 tag rather than shipped as upstream's release binary. govulncheck reports 15 advisories against upstream's release binary, which was built with Go 1.27.1. They are Go standard library advisories in HTTP/2, net/http, crypto/x509, crypto/tls, mime and html/template that Go 1.27.2 and 1.27.3 fix, plus advisories in golang.org/x/net 0.58.0, go.opentelemetry.io/otel/sdk 1.44.0 and google.golang.org/grpc 1.83.1.

SecureAnyCloud built the same tag with Go 1.27.3 and raised those three libraries to x/net 0.62.0, otel/sdk 1.45.0 and grpc 1.83.2. Go's module resolution raised a few of their own dependencies with them. No line of tusd's Go code was changed. Only go.mod and go.sum differ, and the diff is on the image.

The build is reproducible: the same inputs always give a binary with the SHA-256 below, and the image build refuses any other.

cat /usr/share/doc/sac/tusd-BUILD-INFO.txt
tusd 2.10.1 on Ubuntu 24.04 (sac AWS bundle member storage) source https://github.com/tus/tusd/archive/refs/tags/v2.10.1.tar.gz sha256 ab47dd763c7d5269b13738cc731ea85b0670b62f49bccf046587b2588e71bd95 (tag v2.10.1 = 388a27cb81c33b2349e0308b5e97e23797e86fd9); MIT, LICENSE.txt sha256 6ffc8271ebdc9f41112f385e7a80ca0c5167650883c90c4727848f5dc291fb5c -> /usr/share/doc/tusd/ built from that source with go1.27.3 (https://go.dev/dl/go1.27.3.linux-amd64.tar.gz sha256 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40), -trimpath, CGO_ENABLED=0; no tusd source line changed dependency bumps (d-320, govulncheck): golang.org/x/net@v0.62.0 go.opentelemetry.io/otel/sdk@v1.45.0 google.golang.org/grpc@v1.83.2 -> go.mod sha256 9bbbce6aa1ea682dbe171a81e5b3d3c82b87d16e3d048998bba9866cefe8f1b9, go.sum sha256 bef1510a9908811d175c3d856ed92c264c6c118e87c3dc47a7ed1de7ec5e7fc9; diff: tusd-dependency-bumps.diff binary /usr/local/bin/tusd sha256 f61a610a9604abb30e2f67b33af7d492c33dae1d2dc7fd4769f85c69b8642413 (reproducible); go version -m: tusd-go-buildinfo.txt third-party notices: /usr/share/doc/tusd/third-party/ (95 Go modules, sources listed in SOURCES.txt) nginx 1.24.0-2ubuntu7.19 (Ubuntu main) built 2026-10-31T11:42:12Z
sha256sum /usr/local/bin/tusd head -1 /usr/share/doc/sac/tusd-go-buildinfo.txt awk '$1=="dep" && ($2=="golang.org/x/net" || $2=="go.opentelemetry.io/otel/sdk" || $2=="google.golang.org/grpc") {print $2, $3} $1=="build" && ($2=="-trimpath=true" || $2=="CGO_ENABLED=0") {print "build", $2}' /usr/share/doc/sac/tusd-go-buildinfo.txt
f61a610a9604abb30e2f67b33af7d492c33dae1d2dc7fd4769f85c69b8642413 /usr/local/bin/tusd /usr/local/bin/tusd: go1.27.3 go.opentelemetry.io/otel/sdk v1.45.0 golang.org/x/net v0.62.0 google.golang.org/grpc v1.83.2 build -trimpath=true build CGO_ENABLED=0

On the rebuilt binary, govulncheck reports no reachable advisory. One remains at module level: GO-2026-5932, for the golang.org/x/crypto/openpgp package, which is deprecated and unmaintained and has no fixed release. tusd does not import that package and it is not linked into the binary, so the advisory cannot be reached. It is listed only because the golang.org/x/crypto module is a dependency.

Where things are on the image:

ls /usr/share/doc/tusd/ /usr/share/doc/sac/
ls -d /usr/share/doc/tusd/third-party/*/ | wc -l sed -n '1,2p' /usr/share/doc/tusd/third-party/SOURCES.txt
/usr/share/doc/sac/: tusd-BUILD-INFO.txt tusd-dependency-bumps.diff tusd-go-buildinfo.txt /usr/share/doc/tusd/: LICENSE.txt third-party 95 Go modules linked into /usr/local/bin/tusd (go version -m), each unmodified, with the URL of its exact source. tusd itself: https://github.com/tus/tusd/archive/refs/tags/v2.10.1.tar.gz (MIT); the go.mod/go.sum change: /usr/share/doc/sac/tusd-dependency-bumps.diff
  • /usr/share/doc/tusd/LICENSE.txt: tusd's MIT license.
  • /usr/share/doc/tusd/third-party/: the license and notice files of each of the 95 Go modules linked into the binary, and SOURCES.txt with the address of each module's exact source. Two modules are under the Mozilla Public License 2.0 (hashicorp/go-plugin and hashicorp/yamux, unmodified, used only by tusd's plugin hooks, which are off). The rest are under Apache 2.0, MIT, BSD or zlib licenses.
  • /usr/share/doc/sac/: tusd-BUILD-INFO.txt, the go.mod/go.sum diff (tusd-dependency-bumps.diff) and the full build information of the binary (tusd-go-buildinfo.txt).

tusd's source is at github.com/tus/tusd (tag v2.10.1). tus and tusd are open-source projects of the tus organization, originally by Transloadit. SecureAnyCloud is not affiliated with or endorsed by them.

Troubleshooting

  • 401 Unauthorized. The password is wrong or missing. Read it again from /root/tusd-credentials.txt. After tusd-set-password, the old one is refused at once.
  • 403 Forbidden from a browser. The page's origin is not allowed for CORS (Step 9).
  • 413 Request Entity Too Large. The upload, or one request body, is larger than the cap (Step 7).
  • 429 Too Many Requests. A client is over the rate limits (Step 7). tus clients retry with their retry delays.
  • Certificate errors. The client does not trust this instance's certificate, or it connects with a name that the certificate does not carry. Trust tusd.crt (Step 6) or install your own (Step 10).
  • The connection is dropped with no answer. The request named a host that the instance does not serve. Use the name in TUSD_ENDPOINT, or set yours with tusd-set-hostname.
  • Logs. Check journalctl -u tusd, /var/log/nginx/access.log and /var/log/nginx/error.log, journalctl -u tusd-expire, and /var/log/sac/tusd-firstboot.log.

Support

SecureAnyCloud images come with 24/7 support. Email sales@secureanycloud.com with your AWS account ID, the instance ID and the output of sudo /usr/local/sbin/tusd-verify.sh. For tusd itself, see the tusd documentation and the tus protocol.

Deploy on AWS

Launch tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS