tusd Resumable Upload Server 2.10.1 on AWS User Guide
Overview
This guide covers launching and using the tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg 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 and 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; port 80 only redirects to it.
The cloudimg image locks tusd down:
- 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. An unauthenticated or wrong-password client is refused before nginx reads its request body, so it 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, runs as the unprivileged
tusdaccount, and 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, and 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, because upstream's release binary carried advisories that a newer toolchain fixes (see Build provenance and licence).
- Your data on its own volume. Uploads live on a dedicated 32 GiB data volume at
/var/lib/tusdthat you can grow and snapshot on its own. Incomplete uploads are cleaned up after 7 days; completed uploads are kept until you delete them. - Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
What is on the image: tusd 2.10.1 (MIT licence), nginx from Ubuntu 24.04, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
Default user: ubuntu · Architecture: x86_64 · 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, and 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.
Step 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 and your Region, then 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.
With the AWS CLI (replace the values in angle brackets; this 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}]'
Step 2: Connecting to your instance
| Version | SSH user |
|---|---|
| tusd Resumable Upload Server 2.10.1 on Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
First boot works out the instance's name, generates the certificate and the password, starts tusd, proves a full upload round trip over HTTPS and only then opens the web server. It 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 ^cloudimg-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: cloudimg source rebuild go1.27.3
If nginx is not running, first boot has not finished, or it failed, in which case the endpoint is never opened. Check systemctl status tusd-firstboot and sudo tail -40 /var/log/cloudimg/tusd-firstboot.log.
Step 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 (an unauthenticated OPTIONS request, which tus clients and browsers send first); it 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, and 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). It 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)
Step 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 a private work directory in memory (/dev/shm), a copy of the instance's certificate, and a curl configuration file that holds the password, so the password is 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) metadata, base64 encoded. 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 part way. 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 get 401, and an unauthenticated client is refused before its body is read: the 64 MiB upload below is answered at once with almost nothing sent. An upload larger than the cap gets 413, from tusd when the declared Upload-Length is too large and from nginx, 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
Step 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 only you can read, and 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, stops part way as an interrupted client would, then resumes in a fresh uploader from the offset the server reports, downloads the result and 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, and retries so it 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, CORS must be opened first (Step 9), and read the warning there before you put the password into a web page.
Step 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, including tus-js-client's, 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: 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, restarts tusd and 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; tus clients retry. 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
Step 8: Incomplete uploads expire, complete uploads are kept
tusd's file store never deletes anything by itself, so an upload 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) and 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. Completed uploads are never deleted by it; they stay until you delete them (DELETE on the upload URL, or remove the files under /var/lib/tusd/uploads).
systemctl list-timers tusd-expire.timer --no-pager | head -2
grep '^TUSD_EXPIRE_DAYS=' /etc/cloudimg/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.
Step 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, and nginx refuses any request but OPTIONS, GET and HEAD that carries an Origin you have not allowed, before checking the password. 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 *), while 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 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 the browser has saved. Your page has to send the Authorization header itself (tus-js-client and Uppy headers option), which 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 it again with:
$ sudo tusd-configure --cors-origin none
tusd-configure: max-size 5368709120 bytes; CORS origins: none; expire-days 7
Step 10: Your own host name and certificate
On every boot tusd-set-host works out the name to serve: the name you set, else the instance's public DNS name from the instance metadata service (IMDSv2). Every upload URL 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:
$ 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/cloudimg/tusd-hostname for every later boot, the credentials file is updated, nginx serves that name and redirects the instance's other addresses to it. Because the certificate in place was the one cloudimg generated, it was re-created for the new name.
Which certificates are replaced. Only a certificate cloudimg generated is ever re-created: its SHA-256 fingerprint is recorded, root owned, in /var/lib/cloudimg/tusd-cert.sha256, and a certificate is treated as cloudimg's only while it matches that record:
sudo cat /var/lib/cloudimg/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, including a symlink to a chain you manage elsewhere, is yours and is never replaced: not on boot, not when the name changes:
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 (cloudimg).
$ 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: 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, sudo rm /etc/cloudimg/tusd-hostname && sudo tusd-set-hostname --boot.
Step 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.
Step 12: Updates
Ubuntu packages, nginx included, are updated by unattended security upgrades; to update everything now run sudo apt-get update && sudo apt-get upgrade. tusd itself is not an apt package: it is the binary cloudimg 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
cloudimg publishes a new version of this listing when tusd has a new release or a security fix. To move, 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; it returns tus headers only. - Method override. tus clients in environments without
PATCHorDELETEmay sendPOSTwithX-HTTP-Method-Override. The header is honoured only on an authenticatedPOST; any other request carrying it, above all an unauthenticatedOPTIONS, is refused with400before it reaches tusd (Step 5). - Sandboxed downloads. Every response under
/files/carriesContent-Security-Policy: sandbox,X-Content-Type-Options: nosniff,X-Frame-Options: DENYand 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(groupwww-data, so only nginx can open it), runs as the unprivilegedtusdaccount 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-Foris set from the connection, never passed through), and strips theAuthorizationheader. - Instance metadata. tusd, nginx and the expiry job are denied the EC2 instance metadata service (169.254.169.254 and its IPv6 address) by systemd. 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 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 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 only unix sockets; nginx, which has to serve the network, is denied 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 licence
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: 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, and 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. cloudimg 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/cloudimg/tusd-BUILD-INFO.txt
tusd 2.10.1 on Ubuntu 24.04 (cloudimg 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/cloudimg/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/cloudimg/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/cloudimg/
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/cloudimg/:
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/cloudimg/tusd-dependency-bumps.diff
/usr/share/doc/tusd/LICENSE.txt: tusd's MIT licence./usr/share/doc/tusd/third-party/: the licence and notice files of each of the 95 Go modules linked into the binary, andSOURCES.txtwith 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 licences./usr/share/doc/cloudimg/:tusd-BUILD-INFO.txt, thego.mod/go.sumdiff (tusd-dependency-bumps.diff) and the full build information of the binary (tusd-go-buildinfo.txt).
tusd's source: github.com/tus/tusd (tag v2.10.1). tus and tusd are open source projects of the tus organisation, originally by Transloadit; cloudimg 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. Aftertusd-set-passwordthe old one is refused at once.403 Forbiddenfrom 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 connects with a name 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 the instance does not serve. Use the name in
TUSD_ENDPOINT, or set yours withtusd-set-hostname. - Logs:
journalctl -u tusd,/var/log/nginx/access.logand/var/log/nginx/error.log,journalctl -u tusd-expire, and/var/log/cloudimg/tusd-firstboot.log.
Support
cloudimg images come with 24/7 support. Email support@cloudimg.co.uk 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.