Skip to content

Deployment

The hosted instance at https://vistilla.sylergy.workers.dev runs entirely on free tiers, none of which needed a payment card.

 user --HTTPS--> Cloudflare Worker --HTTPS--> Render free web service (Docker)
                  |  proxy, "starting" page         | vistilla-api + ffmpeg + Litestream
                  |  cron ping every 10 min         |
                  +---------------------------------+
                                                    v
                                      Backblaze B2 bucket (S3 API)
                                      videos/  thumbs/  litestream/
                                                    ^
   video bytes <-- Worker /_b2/ <-- 307 presigned --+ (VISTILLA_STREAM_REDIRECT=1)
                    email --> Google Apps Script relay --> Gmail

How a change goes live

The website (web/: pages, scripts, styles, the Android APK) and the docs are served by the Cloudflare Worker from its own copy, not from the server's image. A change to them goes live without Render building anything:

  1. scripts/worker-site.sh gathers web/ and the built docs (MkDocs from $MKDOCS, the PATH or ~/.cache/vistilla-mkdocs; it stops, changing nothing, when MkDocs is missing or the docs fail to build) into worker/site and writes the commit to /assets/web-version.txt.
  2. npx wrangler deploy in worker/ uploads them (only the changed files).

The Worker still asks the server for each page, and keeps the server's security headers (its Content-Security-Policy) and, on a recording's or a share's page, the link-preview title and tags the server writes; only the page itself is the Worker's copy. /assets/version.txt stays the server's. The website goes live even while the server is being rebuilt or is waiting for a deploy.

The Worker also notes each team's color as its members' GET /api/me goes past (Workers KV VISTILLA_ACCENTS, by the team's name) and adds it to an invitation (GET /api/invites/{token}) whose answer lacks it, so an invitation page wears the team's color whatever server version runs.

The server is built on a developer's machine, not on GitHub or Render, so neither spends anything (Render's free build minutes run out, and GitHub Actions in a private repository are billed past a monthly allowance):

  1. Commit, then run scripts/deploy-server.sh. It builds the hosted stage of docker/Dockerfile with a memory-capped builder (vistilla, with public DNS) and pushes it to Docker Hub as docker.io/mohamedalizouariaieng/loom:latest (private) and :<short commit>.
  2. The Render service's source is that image (credential dockerhub, a read-only Docker Hub token). In Render: Manual Deploy → Deploy latest reference, or set VISTILLA_RENDER_DEPLOY_HOOK in your shell to the service's Deploy Hook and the script calls it.
  3. The new container starts. Its entrypoint:
    • writes the commit hash to /assets/version.txt;
    • checks that the bucket will take the database's backup (litestream ltx, two listings), and stops if not, leaving the running server serving: a server whose backup cannot start would lose what is written to it;
    • takes the database over from the running server (vistilla-api take-over $RENDER_EXTERNAL_URL /data/vistilla.db). The old server stops taking changes (503 with X-Vistilla-Updating, which the website and the app send again), waits for those under way, and hands over a copy. Nothing written is lost and the bucket is not read. If the old server answers but cannot hand over, the deploy stops and it carries on; if the new one never starts, the old one carries on by itself after 10 minutes;
    • only when no server is running (or it is a version that does not hand over): restores the database from the bucket;
    • starts vistilla-api under Litestream.
  4. Render switches traffic once /api/healthz answers.

To see which commit is live, open /assets/version.txt (the server) and /assets/web-version.txt (the website).

The Docker image (docker/Dockerfile)

Stage Base Does
builder rust:1-bookworm cargo build --release --locked for vistilla-api and vistilla-cli. Uses 2 parallel jobs and CFLAGS=-O1 to stay under about 1 GB of memory. Cargo caches persist between builds.
test builder cargo test --release --locked. Opt-in with --target test.
docs python:3.12-slim Builds this documentation with MkDocs Material.
runtime debian:bookworm-slim Installs ca-certificates, curl and ffmpeg, then copies the binaries, web/ and the built docs. Runs as user vistilla (uid 10001). Listens on 8787, with a health check on /api/healthz.
hosted runtime + litestream/litestream:0.5 Adds Litestream and the hosted entrypoint.

Render (render.yaml)

  • A web service on the free plan, region Frankfurt. The hosted instance runs the prebuilt image above (loom-tydj.onrender.com); render.yaml describes the same service built from the repository, for anyone who would rather Render build it.
  • The free workspace has 5 GB of outbound bandwidth a month; past it, every service in the workspace is suspended until the next month. Keep what leaves the server small (video bytes go from the bucket through the Worker, not through Render).
  • No disk: the filesystem is wiped on every restart. That is why the database is replicated to the bucket.
  • Health check: /api/healthz.
  • Environment: see the hosted example. Secrets (bucket keys, mail relay URL) are set in the dashboard, and VISTILLA_SECRET_KEY is generated by Render.

A second bucket, ideally in a second free Backblaze account (its own daily allowance), keeps a copy of everything (storage/mirror.rs):

  1. Create the account and a private bucket; in its lifecycle settings choose Keep only the last version.
  2. Create an application key for that bucket (read and write).
  3. On Render, set VISTILLA_MIRROR_S3_ENDPOINT (e.g. https://s3.eu-central-003.backblazeb2.com), VISTILLA_MIRROR_S3_REGION, VISTILLA_MIRROR_S3_BUCKET, VISTILLA_MIRROR_S3_ACCESS_KEY, VISTILLA_MIRROR_S3_SECRET_KEY and VISTILLA_MIRROR_S3_DOWNLOAD_PROXY=https://vistilla.sylergy.workers.dev/_b2m.
  4. On the Worker, set B2_MIRROR_ORIGIN to the same endpoint and deploy it.

The server copies what is already there in the background, newest first, one object at a time; the table mirrored records what is copied. One the first bucket does not hold at all (a recording whose file was lost) is noted once in mirror_missing and not asked for again; it stays in the library, without a picture or playback. Reads from the first bucket go through the Worker, so copying does not use its daily download gigabyte (one read per recording, whatever its size).

The database is copied there every 6 hours when it has changed (each copy is the whole database leaving the host, whose free bandwidth is a few GB a month), and once more as the server stops (loom-db/loom.db).

When the first bucket refuses at start-up

If the first bucket will not take the database's backup (its daily allowance is used up), the start-up script does not wait for midnight UTC:

  1. It checks the second bucket will take it instead (docker/litestream-mirror.yml).
  2. It takes the database from the running server, as usual; with none running, from the second bucket: its Litestream copy if that is the newest, else the plain copy made as the last server stopped.
  3. It writes mirror to loom-db/home in both buckets (a write is never refused by the cap), then Litestream ships every change to the second bucket (loom-db/live) within a second.

The next start that finds the first bucket willing reads loom-db/home there, restores from the second bucket when it says mirror, and ships to the first bucket again; 10 seconds after it starts, the server writes bucket back. A start that cannot read loom-db/home does not start, rather than start from a copy that may be behind.

Litestream (docker/litestream.yml)

  • Replicates /data/vistilla.db to s3://$VISTILLA_S3_BUCKET/litestream/loom.db in the same bucket as the videos.
  • Tuned to keep the number of requests low: 5-minute and 1-hour levels, 10-minute retention for the most recent changes, and a look for old pieces to delete once an hour.
  • docker/hosted-entrypoint.sh runs litestream restore -if-db-not-exists -if-replica-exists, then litestream replicate -exec vistilla-api (into the second bucket instead while the first refuses; see above).
  • Set VISTILLA_DB_REPLICA=off, or use a non-S3 store, to start vistilla-api directly.
  • If the bucket is out of reach (for example B2's free daily download cap is used up, so the restore fails), set VISTILLA_STANDBY_S3_* to another bucket. The site starts from that bucket, empty the first time. Delete the variables and redeploy to go back: the usual bucket is untouched.

VISTILLA_STORAGE=r2 is not replicated

The server treats r2 like s3, but the start script only runs Litestream when the value is exactly s3. With r2 on a host with no disk, the database would be lost on every restart. Use s3 for R2 too.

Cloudflare Worker (worker/)

The public address is a Cloudflare Worker that forwards every request to the Render service:

  • It passes the method, headers and body through, adding X-Forwarded-Host, X-Forwarded-Proto and X-Forwarded-For.
  • It rewrites redirects that point at the Render address back to the public address. Redirects to the bucket pass through untouched.
  • While Render wakes from sleep (free services sleep when idle), page visits that fail or get a 502, 503 or 504 see a self-refreshing "Vistilla is starting" page. API calls that cannot reach Render at all get a JSON 503 with Retry-After.
  • /_b2/… is passed on to the bucket (B2_ORIGIN) instead of Render: only GET and HEAD, with Range, If-Range, If-None-Match and If-Modified-Since. This is how downloads reach browsers and the server without counting against B2's daily allowance. It fetches with cache: "no-store": Cloudflare counts a .webm as a static file, and would otherwise answer a range of an uncached one only after fetching the whole object, so a jump in a long recording never landed. The Worker is deployed by hand (cd worker && npx wrangler deploy), not by a push.
  • A cron trigger every 10 minutes pings /api/healthz to keep the service awake.
  • Cloudflare limits request bodies to 100 MB. That is why a file sent in one request is capped at 100 MB here. Recordings go up in 5 MB parts, and library uploads over 64 MB in 16 MB parts, so they are not affected.

Deploy it with:

cd worker
npx wrangler deploy --var ORIGIN:https://<your-render-service>.onrender.com \
  --var B2_ORIGIN:https://s3.<region>.backblazeb2.com

The old address

Until 2026-10-20 the project was called Loom and lived at loom.sylergy.workers.dev. That address is now a small Worker of its own (worker/legacy/, deployed with cd worker/legacy && npx wrangler deploy):

  • a page opened there (an old email, a bookmark) is sent to the same page at vistilla.sylergy.workers.dev (301);
  • everything else (API calls from the old Android app, videos, /_b2/) is passed on to the new Worker through a service binding, since a Worker cannot fetch another one's workers.dev address;
  • /assets/app/loom.apk leads to vistilla.apk, so the old app's update card installs the new one. The new app has its own ID (com.sylergy.vistilla), so it installs beside the old one, which can then be removed.

The server still accepts the old session cookie (loom_session), so the old app stays signed in until it is replaced.

The Docker Hub image keeps its name (mohamedalizouariaieng/loom): a free Docker Hub account has one private repository.

Backblaze B2

  • An S3-compatible bucket holds the videos, posters and the database replica.
  • Players are redirected to presigned URLs valid for 4 hours (VISTILLA_STREAM_REDIRECT=1): a browser keeps the link and asks it again for every jump, so it has to last as long as someone may watch. The server's own ffmpeg jobs read through presigned URLs too, valid for 6 hours. Video bytes then go from B2 to the browser, not through Render.
  • Every download, by a browser or by the server's own jobs, goes through the Worker at /_b2/ (VISTILLA_S3_DOWNLOAD_PROXY). The URL is still signed for B2's own address, and the Worker only passes the request on (GET and HEAD, with Range), so a private bucket stays private.

Why downloads go through Cloudflare

B2's free plan includes 1 GB of downloads a day, and an account with no card is blocked past it, until midnight UTC. That block includes the database copy the server reads when it starts, so the whole site goes down. On 2026-10-18 a server stuck re-reading a 1.87 GB recording used it up. Downloads from B2 to Cloudflare are free (the Bandwidth Alliance), which is why they are routed through the Worker.

Email

VISTILLA_MAIL_WEBHOOK_URL points at a Google Apps Script web app (scripts/mail-relay.gs) that sends from a Google account. On the hosted instance that account is mohamedalizouari.eng@gmail.com, so every email comes from it, under the name "Vistilla". That is about 100 emails a day, plenty for sign-up codes, invitations, shares and resets.

A Gmail address is used rather than one at a company domain on purpose: Google signs mail from @gmail.com itself (SPF and DKIM pass), so it is trusted. Mail sent this way from sylergy.net landed in spam, because that domain's SPF record does not list Google and it has no DKIM key; whoever manages its DNS could fix that (include:_spf.google.com in the SPF record, and the google._domainkey record from Google Admin → Gmail → Authenticate email). Links to a workers.dev address still count a little against any mail; a domain of your own avoids that.

Setting up the relay

Deploy the script while signed in to only the sending account (a private window works). With several Google accounts signed in, Google's permission window fails with "Sorry, unable to open the file".

To check a relay without sending anything: curl -L -d '{"probe":true}' <exec url> should answer {"ok":false,"error":"to, subject and text are required"}.

Running it elsewhere

Any host that runs a Docker container works:

  • With a persistent disk: use the runtime target, mount /data, and keep VISTILLA_STORAGE=fs. No bucket or Litestream needed.
  • Without a disk: use the hosted target with an S3-compatible bucket, as above.

Behind HTTPS, always set VISTILLA_PUBLIC_BASE_URL and VISTILLA_SECURE_COOKIES=true, and set VISTILLA_SECRET_KEY so stored AI keys survive a restart.