Skip to main content
Premium

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:

  1. start with Config::Tus.tmp_prefix — otherwise it raises a bad-request error (this prefix check is the security boundary: it keeps finalize from reading or deleting files outside the temporary upload area);
  2. end with the original filename — otherwise the finalized file loses its name and extension (finalize reads the file at File.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 exp claim using the shared PRIVATE_API_KEY — the same secret eluvia-auth signs access tokens with;
  • lets CORS preflight (OPTIONS) requests through unauthenticated (tusd emits the CORS headers);
  • forwards everything else to tusd only 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
caution

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​