tus server setup
Run a tusd service and point Config::Tus.endpoint at it. The
frontend uploads to tusd via the TUS resumable protocol; tusd stores the object under
Config::Tus.tmp_root, and habarico moves it into the target ActiveStorage attachment on save
(Providers::Tus#finalize).
For this to line up, the temporary object must land at a storage key habarico accepts. The
frontend derives that key (upload_key) from the TUS upload URL, i.e. it is exactly the upload ID
tusd assigns. finalize requires the key to:
- start with
Config::Tus.tmp_prefix— otherwise it raises a bad-request error (this prefix check is the security boundary: it keepsfinalizefrom reading or deleting files outside the temporary upload area); - end with the original filename — otherwise the finalized file loses its name and extension
(
finalizereads the file atFile.join(Config::Tus.tmp_root, upload_key)).
tusd assigns a random, prefix-less, name-less ID by default, which satisfies neither. A
pre-create hook rewrites the upload ID (and storage path) into the required shape:
<tmp_prefix><uuid>/<original_filename> e.g. tmp/uploads/9f3a.../photo.jpg
Pre-create hook
A ready-to-use hook lives at scripts/tusd/pre-create in the habarico-enterprise
repository. It is an operational artifact for the tusd container, not part of the installed gem, so
copy it from that source tree at the tag matching your installed habarico-enterprise version into
your tusd -hooks-dir. It
reads the TUS event as JSON on stdin, takes filename from the upload metadata (stripped to its
basename to defuse path traversal), generates a UUID, and returns a ChangeFileInfo response
setting both the upload ID and the storage Path to <tmp_prefix><uuid>/<filename>. Uploads
without a filename metadata value are rejected with HTTP 400 (a name-less object would lose its
extension).
The prefix is read from the HABARICO_TUS_TMP_PREFIX environment variable (default
tmp/uploads/). It must match Config::Tus.tmp_prefix in your Rails initializer.
The hook needs only a POSIX shell — no jq required.
Storage mapping
tusd must write to the same directory Providers::Tus#finalize reads from
(Config::Tus.tmp_root).
services:
tusd:
image: tusproject/tusd:latest
command: >-
--upload-dir /app/storage/private
--hooks-dir /srv/tusd-hooks
--base-path /files/
--cors-allow-origin https://admin.example.com
environment:
# Must match Config::Tus.tmp_prefix in the Rails initializer.
HABARICO_TUS_TMP_PREFIX: "tmp/uploads/"
volumes:
# Same volume the Rails app writes/reads ActiveStorage's Disk service from, so that
# Config::Tus.tmp_root resolves to a path finalize() can open.
- ../var/storage:/app/storage
# Hook script — path relative to your docker-compose file.
- ../vendor/habarico/scripts/tusd:/srv/tusd-hooks
Set Config::Tus.tmp_root to the same path the Rails container mounts that volume at, e.g.
/app/storage/private.
CORS
The admin frontend talks to tusd cross-origin, so allow its origin:
tusd -cors-allow-origin 'https?://admin\.example\.com'
# add -cors-allow-credentials if the frontend sends cookies/auth to tusd
tusd already exposes the TUS protocol response headers the frontend needs (Location,
Upload-Offset, Upload-Length, Tus-Resumable, ...); use -cors-expose-headers only for extra
custom headers.
Securing tusd (JWT)
By default tusd is unauthenticated — anyone who can reach its endpoint can write files to the
temp storage (finalize itself is still protected by the admin's authentication, but the temp
prefix is open). For public deployments, put a JWT-validating reverse proxy in front of tusd.
No backend or frontend change is required: the frontend uploader (Uppy's tus plugin) already sends
the auth service's access token as Authorization: Bearer <jwt> on every tus request. The proxy
simply verifies that token and rejects requests without a valid one.
scripts/tusd-proxy/ in the habarico-enterprise repository contains a ready-made
Caddy proxy (with the
caddy-jwt plugin) that:
- verifies the token signature (HS256) and its
expclaim using the sharedPRIVATE_API_KEY— the same secreteluvia-authsigns access tokens with; - lets CORS preflight (
OPTIONS) requests through unauthenticated (tusdemits the CORS headers); - forwards everything else to
tusdonly when the token is valid.
The key is configured once as the raw PRIVATE_API_KEY; entrypoint.sh derives the base64 form
caddy-jwt expects at startup, so the secret is never duplicated.
services:
tus-proxy:
build: ../vendor/habarico/scripts/tusd-proxy
environment:
# Same shared HS256 secret eluvia-auth uses to sign access tokens.
PRIVATE_API_KEY: ${PRIVATE_API_KEY}
# Optional, defaults to tusd:8080
# TUS_UPSTREAM: tusd:8080
ports:
- "1080:8080" # public entry — point Config::Tus.endpoint here
tusd:
image: tusproject/tusd:latest
command: >-
--upload-dir /app/storage/private
--hooks-dir /srv/tusd-hooks
--base-path /files/
--cors-allow-origin https://admin.example.com
environment:
HABARICO_TUS_TMP_PREFIX: "tmp/uploads/"
# No `ports:` here — tusd is no longer exposed directly; only tus-proxy is reachable.
volumes:
- ../var/storage:/app/storage
- ../vendor/habarico/scripts/tusd:/srv/tusd-hooks
Revocation/logout is not enforced at the proxy — it only checks the signature and exp, so a
logged-out user's token is accepted until it expires. This is acceptable for temp uploads; keep the
access-token lifetime short.
HS256 is symmetric — the proxy holds a secret that can also mint tokens. That is fine within the same trust domain (it is the secret every service already shares), but treat the proxy accordingly.
Temporary file cleanup
Finalized objects are removed from the temp path on save (the temp original, and its .info
sidecar tusd's filestore writes next to it, are cleaned up by finalize itself — see the source
comment on Providers::Tus#finalize). Uploads the user never finishes still accumulate under
Config::Tus.tmp_root; prune them periodically with a scheduled job.
See also
- Configuration —
Config::Tus.endpoint/tmp_prefix/tmp_root - Provider selection — when tus is chosen over S3