Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Multimedia and storage provision

Tuwunel handles media uploads, remote media fetching, thumbnail generation, URL previews, and blurhash generation. This chapter covers configuration for all of these features, as well as the storage backends that back them.

  • Storage providers — Local filesystem and S3-compatible object storage backends.

  • Media management — Commands for inspecting, deleting, and bulk-removing media, including spam response.

Upload limits

OptionDefaultDescription
max_request_size24 MiBMaximum size of a single media upload. Accepts SI/IEC units, e.g. "50 MiB".
max_pending_media_uploads5Maximum number of in-progress asynchronous uploads a single user can have at once.
media_create_unused_expiration_time86400Seconds before an unused pending MXC URI is expired and removed (default: 24 hours).
media_rc_create_per_second10Maximum media-create requests per second from a single user before rate limiting applies.
media_rc_create_burst_count50Maximum burst size for media-create rate limiting per user.

Legacy media endpoints

Matrix spec version 1.11 introduced authenticated media endpoints. The older unauthenticated endpoints are deprecated but some clients and servers still use them.

OptionDefaultDescription
allow_legacy_mediafalseServe the unauthenticated /_matrix/media/*/ endpoints locally. The authenticated equivalents are always enabled.
request_legacy_mediafalseFall back to unauthenticated requests when fetching media from remote servers. Unauthenticated remote media was removed around 2024Q3; enabling this adds federation traffic that is unlikely to succeed.

Thumbnails

Thumbnails are generated on demand from the original and cached. A request for a picture larger than the original is answered with the original itself rather than an upscale.

OptionDefaultDescription
media_thumbnail_max_pixels50000000Largest picture the thumbnailer will decode, in pixels. Anything larger is served without a thumbnail. Applies to uploaded pictures and to frames extracted from video.

A generated thumbnail is a PNG, and is served as image/png under the filename thumbnail.png rather than the content type or name of the file it came from. Thumbnails already cached before this was true keep the labelling they were stored with; only newly generated ones are relabelled.

Video thumbnails

Image thumbnails need no further configuration. Video thumbnails do: tuwunel decodes no video itself, so an external program supplies the still frame it thumbnails. Clients that upload a video without a thumbnail of their own otherwise leave nothing to preview.

Point media_video_thumbnail_command at a program that reads a video and writes one frame to standard output. With ffmpeg installed:

media_video_thumbnail_command = [
  "ffmpeg", "-loglevel", "error",
  "-i", "{input}",
  "-vf", "thumbnail",
  "-frames:v", "1",
  "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1",
]

The list is an argument vector, not a shell line: the first entry is the program and the rest are its arguments, passed through without a shell. Every argument has these tokens substituted before each call.

TokenValue
{input}Path of a temporary file holding the source video.
{width}Requested thumbnail width.
{height}Requested thumbnail height.

The frame may be PNG, JPEG, WebP or GIF; tuwunel scales and crops it exactly as it would an uploaded picture, and caches the result, so the program runs once per video and size rather than once per request. The ffmpeg thumbnail filter above picks a representative frame rather than the first, which is often black. The frame comes back at the video’s full resolution; feed {width} and {height} to a scale filter if your users post video large enough for that to matter.

A video whose frame cannot be produced is served whole, as it was before any of this, so a failure costs a preview and nothing else. Failures are logged with the program’s own standard error, which is where a misconfigured command reports itself.

OptionDefaultDescription
media_video_thumbnail_command[]Argument vector of the frame-extraction program. Empty leaves videos without thumbnails.
media_video_thumbnail_timeout30Seconds allowed per request, counted from the request rather than the spawn so a queue cannot compound the wait. On expiry the program and anything it spawned are killed.
media_video_thumbnail_concurrency1Programs permitted to run at once. Requests past this wait for a slot. Raise it where cores are spare; a restart is required to apply a change.
media_video_thumbnail_max_size128 MiBLargest video staged for the program. A larger one is served without a thumbnail.
media_video_thumbnail_path<database_path>/tmpDirectory a video is staged in, one file per running program.

Bounding what a video can cost

Everything the program sees comes from an upload, so three limits stand between a crafted file and the host.

media_video_thumbnail_max_size decides which videos are staged at all, and bounds the frame read back: a program offering more is refused rather than truncated into a decode failure. The staged copy is written mode 0600 into media_video_thumbnail_path and removed as soon as the program exits, on the deadline and on a cancelled request alike. That path defaults to a tmp subdirectory of the database rather than the system temporary directory: /tmp is frequently a tmpfs, where staging a large video spends memory rather than disk.

media_thumbnail_max_pixels bounds the decode. A video’s frame arrives at the video’s own resolution, so a file declaring 100000x100000 would otherwise ask the thumbnailer for the memory to hold it. The dimensions are read from the header and checked before any decoder allocates. The default of 50 megapixels is about four 8K frames, budgeted at four bytes per pixel, and the budget is per in-flight request rather than per server, since thumbnail requests are not otherwise limited in number.

The same limit applies to uploaded pictures, where the equivalent file is a decompression bomb, and there it is a tightening: before this existed the effective ceiling was the image decoder’s own 512 MiB allocation default, around 128 megapixels. A picture between that and the new limit will now be served whole instead of thumbnailed. Raise the value if you serve originals that large.

media_video_thumbnail_timeout and media_video_thumbnail_concurrency bound time and parallelism. On expiry the whole process group is killed, not merely the program tuwunel started, so a wrapper script cannot leave its own decoder running. The same happens when the request is cancelled or the server shuts down.

A video the program fails on, or whose frame the thumbnailer then refuses, is left alone for five minutes rather than retried on the next request. Without that, one upload the decoder chokes on would spend a slot on every thumbnail request it received, at any size, and at the default concurrency of one that is the slot every other video is waiting for. Only the program’s own verdict counts: a request that gave up waiting for a slot, or that reached the front of the queue with too little of its deadline left to give the program a fair run, says nothing about the video and is not remembered. The cost is that fixing a misconfigured command does not take effect for already-failed videos until the interval passes.

The decoder’s memory is not tuwunel’s to cap: it is a separate process. Under systemd it is spawned into the service’s cgroup, so a MemoryMax= on the unit covers it. Note what else that covers, though: the cgroup holds tuwunel too, and tuwunel’s own footprint is dominated by the RocksDB block cache, so a limit chosen for the decoder alone will be far below what the server needs and will get the server killed instead. Size MemoryMax= for the whole service, or leave it unset and rely on the limits above.

Shutdown, reload and restart

A running extraction never delays a shutdown past its own deadline, and never outlives the server that started it.

The program runs inside the request that asked for the thumbnail. On shutdown that request is given the client_shutdown_timeout grace period and is then dropped, which kills the program’s process group, releases its slot and unlinks the staged video. A client that disconnects mid-request has the same effect at once. Nothing waits on the program itself, so the stop time is bounded by the grace period rather than by media_video_thumbnail_timeout.

A configuration reload does not disturb work in flight. An extraction already under way keeps the deadline it computed at entry and finishes under it; the next request reads the new values. media_video_thumbnail_concurrency is the exception, as it sizes a semaphore built at startup.

Two cases skip the orderly path. An in-place restart replaces the running image without unwinding, and a SIGKILL gives nothing a chance to run, so either can leave a staged video behind. Tuwunel reclaims those at startup by sweeping its staging directory, which is why that directory should hold nothing else. Under systemd the decoder is in the unit’s cgroup, so stopping the service kills it along with everything else in the unit.

What the program is exposed to

The video handed to the program is whatever someone uploaded, on your server or on any server you federate with, since a remote original cached locally is thumbnailed the same way. That is the point of the feature and it cannot be otherwise, so it is worth being precise about what does and does not stand behind it.

Nothing uploaded is ever executed. The staged file is written without an execute bit, given a random name and no extension, and passed to the program as a path argument; the program itself comes from your configuration and nowhere else. Renaming an executable to .png, or to anything, changes none of that. There is no shell, so no argument in the video’s name or type can be interpreted as one.

What remains is the decoder’s own parsing of hostile input, which is the same exposure ffmpeg carries anywhere it is pointed at untrusted media. Note that the video/ check is on the content type the uploader declared, so it selects which media are worth trying, not which are safe; assume anything can reach the program. Tuwunel keeps it at arm’s length: a separate process, so a crash or a corrupted heap is not in the server’s address space; a deadline; a process group killed as a unit; one slot at a time by default; and a size limit before anything is staged. Under the packaged units the program also inherits the service sandbox, which is the substantial part of this: no capabilities, NoNewPrivileges=yes, the unit’s syscall allow-list, ProtectSystem=strict, PrivateDevices=yes and MemoryDenyWriteExecute=yes, that last one frustrating the usual step from a memory-safety bug to executing anything.

Choose the decoder accordingly, keep it patched, and prefer a build with only the demuxers you need if you have one.

systemd

The packaged units filter syscalls down to @system-service @resources and then subtract several sets, @ipc among them. pipe2 belongs to @ipc, and spawning the program with pipes needs it, so the units add the two pipe calls back:

SystemCallFilter=pipe pipe2

A unit predating this, including one you have customised yourself, does not carry that line, and the spawn fails with EPERM until it does. Add it as a drop-in rather than editing the unit:

# /etc/systemd/system/tuwunel.service.d/video-thumbnails.conf
[Service]
SystemCallFilter=pipe pipe2

Nothing else in the shipped hardening obstructs the program: /usr stays readable and executable under ProtectSystem=strict, and PrivateDevices=yes still provides the /dev/null the program’s standard input is bound to. The staging directory is already writable, through ReadWritePaths on the Debian and RPM units and through StateDirectory= on the Arch one; point media_video_thumbnail_path somewhere else and you must grant that path yourself.

Prefer a decoder you would already trust with remote media. Nothing about the mechanism is specific to ffmpeg: any program that reads a video and writes one frame will do.

Containers

The published images carry no decoder. Installing ffmpeg on our own Debian base, with --no-install-recommends, costs about 190 MB of codec libraries, and bundling that would charge every deployment for a feature that is off by default. So the program is yours to supply. Two ways, and the second needs no image of your own.

Derive an image. The runtime is Debian, so this is the whole of it:

FROM ghcr.io/matrix-construct/tuwunel:latest
RUN apt-get update \
 && apt-get install -y --no-install-recommends ffmpeg \
 && rm -rf /var/lib/apt/lists/*

Or mount a decoder in. Because tuwunel runs a program rather than linking a library, a statically linked ffmpeg build is just a file the container needs to see:

--volume /opt/ffmpeg:/usr/local/bin/ffmpeg:ro

with media_video_thumbnail_command pointing at /usr/local/bin/ffmpeg. It must be a static build; one taken from the host will look for libraries the image does not have. This keeps you on the published image and adds one file instead of a dependency tree.

Nothing else about the container needs changing. The program is an ordinary child process, so no added capabilities or privileges, and the default seccomp profile permits the pipe calls that the packaged systemd units have to allow back explicitly. Staged videos land in media_video_thumbnail_path, which defaults inside the database directory, so they go to your data volume rather than the container’s writable layer and a read-only root filesystem is no obstacle.

Blocking remote media

prevent_media_downloads_from is a list of regex patterns matched against server names. Tuwunel refuses to download media originating from any matching server.

prevent_media_downloads_from = [
  "badserver\\.tld$",
  "spammy-phrase",
]

This is useful as a reactive measure after a spam incident. See the Management page for bulk-deletion commands to pair with it.

URL previews

URL previews are disabled unless at least one allowlist is configured. All allowlist checks are evaluated before the denylist check. The URL Previews page covers user agents, the spider budget, caching, and troubleshooting an empty preview.

OptionDefaultDescription
url_preview_domain_explicit_allowlist[]Exact domain matches allowed for previewing. "google.com" matches https://google.com but not https://subdomain.google.com. Set to ["*"] to allow all domains.
url_preview_domain_contains_allowlist[]Substring domain matches. "google.com" matches any URL whose domain contains that string — including unrelated domains. Set to ["*"] to allow all domains.
url_preview_url_contains_allowlist[]Substring match against the full URL (not just the domain). Set to ["*"] to allow all URLs.
url_preview_domain_explicit_denylist[]Exact domain matches explicitly blocked. The denylist is checked first. Setting to ["*"] has no effect.
url_preview_check_root_domainfalseWhen enabled, domain allowlist checks are applied to the root domain. Allows all subdomains of any allowed domain — e.g. allowing wikipedia.org also allows en.m.wikipedia.org.
url_preview_max_spider_size786432Maximum bytes read from a page when generating a preview (default: 768 KiB). A page whose OpenGraph tags sit past this point yields an empty preview.
url_preview_max_media_size52428800Maximum size of a single media item fetched or relayed for a URL preview: the og:image measurement fetch and the lazy-media relay. Media larger than this is not registered, and an over-cap relay is refused (default: 50 MiB).
url_preview_bound_interfaceNetwork interface name or IP address to bind when making URL preview requests. Example: "eth0" or "1.2.3.4".
url_preview_user_agentUser-Agent header sent when fetching pages to extract their OpenGraph tags. Defaults to the versioned server User-Agent, e.g. "Tuwunel/1.8.1 preview".
url_preview_media_user_agentUser-Agent header sent when fetching and relaying preview media files themselves. Falls back to url_preview_user_agent.

Note

Setting any allowlist to ["*"] opens significant attack surface — a malicious client could cause the server to make requests to arbitrary URLs on the local network. Use explicit allowlists wherever possible.

og:image, og:video, and og:audio (and direct links to image, video, and audio files) resolve to an mxc:// URI on this server rather than the third-party URL, and are not copied into media storage: requests for that mxc:// URI are relayed — the server fetches the source URL on the client’s behalf (subject to the same SSRF/CIDR checks as everything else on this page, and capped at url_preview_max_media_size) and passes the content through, so the third party sees the server’s address rather than the client’s. Images are the exception to the lazy fetch: one is downloaded while generating the preview to measure og:image:width/og:image:height and matrix:image:size, and those bytes are staged so the first client download does not fetch the origin a second time. og:video:width/og:video:height are populated when the page declares them. Clients cache the results themselves per the immutable cache headers on media downloads. A preview’s mxc:// URI is only as durable as its source URL: if the source expires or changes, later fetches reflect that, unlike uploaded media. Upstream error responses are never relayed as media.

Blurhash

Tuwunel can generate blurhashes for uploaded images, which clients use to show a blurred placeholder before the full image loads. This requires the blurhashing compile-time feature.

Blurhash settings live in a dedicated config section:

[global.blurhashing]
components_x = 4
components_y = 3
blurhash_max_raw_size = 33554432
OptionDefaultDescription
components_x4Horizontal detail components. Higher values produce more detailed hashes at the cost of a larger hash string.
components_y3Vertical detail components.
blurhash_max_raw_size33554432Maximum raw image size (after decoding to pixel data) that will be blurhashed, in bytes (default: ~32 MiB). Set to 0 to disable blurhashing entirely. Should be at or above max_request_size to avoid silently skipping large uploads.