Self-Hosting Shottr Screenshots on Your Own Domain with Cloudflare
I take a lot of screenshots, and I wanted the shareable links to live on my own domain instead of someone else’s cloud: a short a-hafez.com/... link rather than a long, provider-branded storage URL, and one nobody can use to find my other screenshots. I recently discovered Shottr, and it became my capture tool of choice on macOS. It turns out you can point its uploader at a server you control. This post walks through exactly how I did that with a Cloudflare Worker and R2, including the parts that are not obvious and cost me time.
Before you start, two hard requirements. This only works if you have a paid Shottr Friends Club license: the integration piggybacks on Shottr Cloud’s uploader, and the free tier has no upload mode for the trick to hook into. And because Shottr has no “custom server” option in its UI, you redirect its uploader from the terminal with a hidden
defaults write cc.ffitch.shottr uploadEndpointdefault, set up in step 6. Without both of those, Shottr keeps uploading to its own servers and none of the rest of this applies.
Credit where it’s due
The idea of redirecting Shottr’s uploader at a personal server comes from Pradeep Gowda’s Custom server for Shottr. His version is PHP behind Nginx on a VPS, and it converts captures to WebP. This post is inspired by that one. I took the same uploadEndpoint trick and rebuilt the server side on Cloudflare (a Worker plus R2 instead of a box I have to maintain), and I changed how files are named, which is the one part I think matters most for privacy.
Why naming matters
Shottr does let you customize how files are named. The fileNameTemplate default takes strftime macros plus a few of Shottr’s own:
defaults write cc.ffitch.shottr fileNameTemplate 'Screenshot %Y-%m-%d at %I.%M.%S %p'
The catch is that every macro is deterministic. You get the usual date and time fields (%Y, %m, %d, %H, %M, %S, and so on), plus %Q for the quarter, %n for the captured app’s name, and %t, a four-letter timecode that is just the time of day quantized to quarter-second steps. None of them emits random characters. So whatever template you choose, the filename is a pure function of when you captured and what you captured (the default looks something like SCR-20260528-2ka.png), and anyone holding one link can walk the clock to reconstruct the rest. The btbytes server has the same property, it names files from a base58-encoded timestamp.
That predictability is what I wanted to remove. Shottr cannot add randomness on its own, but it can POST each capture to a custom HTTP endpoint and then use whatever URL that endpoint returns. So the fix is to let the endpoint assign the name. My Worker gives every upload an unguessable random slug, never lists the bucket, and refuses to serve the collection root. A shared link reveals exactly one image and nothing else.
How the whole thing fits together
There is one Worker with two paths:
| Route | Method | What it does |
|---|---|---|
/screenshots/?token=<secret> | POST | Checks the token, reads the multipart file, stores it in R2 under an unguessable key, returns SUCCESS: <public url> as plain text. |
/screenshots/<key> | GET / HEAD | Streams the stored object back with its content type and caching headers. |
The key scheme is YYYY-MM-DD-HH-MM-SS-UTC-<8-char slug>.<ext>. The timestamp gives ordering and collision resistance, the random slug carries all of the security. Eight base62 characters is about 48 bits, sampled from crypto.getRandomValues.
The gotchas (read these before you build)
These are the things that are not obvious from the outside.
- There is no custom-server UI in Shottr. It only shows S3 and Shottr Cloud. The custom endpoint is layered on top of Shottr Cloud mode via the hidden
uploadEndpointdefault. That is why theuploadEndpointoverride in step 6 is mandatory, and why you need the Friends Club license that unlocks Cloud uploads, and the custom endpoint in the first place. - Shottr cannot send custom headers, so the auth token cannot go in an
Authorizationheader. It rides in the query string (?token=...) instead. Compare it in constant time so you do not leak it character by character through timing. - The response format is load-bearing. Shottr’s cloud uploader expects the body to begin with
SUCCESS:followed by the URL, as plain text. Return JSON or a bare URL and Shottr will not pick up the link. - Shottr’s own token field is a red herring. You still have to select Shottr Cloud mode and put something in its token field for the UI to be happy, but the Worker authenticates from the
?token=in the endpoint URL, not from that field. - Immutable caching means delete is not always delete. Objects are served with
Cache-Control: public, max-age=31536000, immutable, which is great for speed but means any browser that has already loaded an image keeps it for a year and never revalidates. Deleting the object from R2 makes the Worker return 404 to new viewers, but it does not reach a copy already sitting in someone’s browser cache, so a link you have shared can keep working for whoever opened it. Plan for that before you treat “delete from the bucket” as “the link is dead.”
Building it
Prerequisites
- A Cloudflare account with a domain (zone) already on Cloudflare. I use
a-hafez.com. - Node and Wrangler installed (
npm i -g wrangler, thenwrangler login). - Shottr with a Friends Club license.
1. Create the R2 bucket
wrangler r2 bucket create screenshots
Leave it private. The Worker is the only thing that reads from or writes to it, and the bucket is never made publicly listable.
2. Write the Worker
This is the whole thing. Save it as src/worker.ts. It uses only Web APIs that exist in both the Workers runtime and Node, so it is unit-testable without Miniflare.
/**
* Screenshot upload + serve Worker.
*
* POST /screenshots/?token=<secret> -> stores the multipart `file` in R2 under an
* unguessable key, returns `SUCCESS: <public url>`
* (the plain-text format Shottr's uploader parses).
* GET /screenshots/<key> -> streams the stored object back with caching headers.
*
* The bucket is never listable; privacy rests on the random slug in each key, so a shared
* link reveals exactly one object and nothing else.
*/
export interface Env {
BUCKET: R2Bucket;
/** Shared secret required on uploads (Shottr can only pass it in the query string). */
UPLOAD_TOKEN: string;
/** Optional override of the max upload size in bytes (default 25 MiB). */
MAX_UPLOAD_BYTES?: string;
}
const ROUTE_PREFIX = "/screenshots/";
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
// The random slug is the only thing protecting one shared link from exposing the
// rest (the timestamp in the key is guessable). 8 base62 chars ~= 48 bits.
const SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const SLUG_LENGTH = 8;
const IMAGE_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"image/heic": "heic",
"image/heif": "heif",
"image/tiff": "tiff",
"image/bmp": "bmp",
"image/avif": "avif",
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
const url = new URL(request.url);
switch (request.method) {
case "POST":
return await handleUpload(request, env, url);
case "GET":
case "HEAD":
return await handleServe(request, env, url);
default:
return text("ERROR: method not allowed", 405, { allow: "GET, POST" });
}
} catch (err) {
console.error(JSON.stringify({ msg: "unhandled error", error: String(err) }));
return text("ERROR: internal error", 500);
}
},
} satisfies ExportedHandler<Env>;
async function handleUpload(request: Request, env: Env, url: URL): Promise<Response> {
if (!(await isAuthorized(url, env))) {
return text("ERROR: unauthorized", 401);
}
const maxBytes = parseMaxBytes(env);
const declaredLength = Number(request.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
return text("ERROR: payload too large", 413);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return text("ERROR: expected multipart/form-data", 400);
}
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return text("ERROR: missing 'file' upload", 400);
}
if (file.size > maxBytes) {
return text("ERROR: payload too large", 413);
}
const contentType = normalizeContentType(file.type);
const key = generateKey(contentType);
await env.BUCKET.put(key, file, {
httpMetadata: { contentType, cacheControl: IMMUTABLE_CACHE },
});
console.log(JSON.stringify({ msg: "uploaded", key, size: file.size, contentType }));
return text(`SUCCESS: ${url.origin}${ROUTE_PREFIX}${key}`, 200);
}
async function handleServe(request: Request, env: Env, url: URL): Promise<Response> {
const key = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length));
if (!url.pathname.startsWith(ROUTE_PREFIX) || key === "") {
return text("ERROR: not found", 404);
}
const object = await env.BUCKET.get(key);
if (object === null || !("body" in object) || object.body === null) {
return text("ERROR: not found", 404);
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
headers.set("x-content-type-options", "nosniff");
if (!headers.has("cache-control")) {
headers.set("cache-control", IMMUTABLE_CACHE);
}
if (request.method === "HEAD") {
headers.set("content-length", String(object.size));
return new Response(null, { headers });
}
return new Response(object.body, { headers });
}
async function isAuthorized(url: URL, env: Env): Promise<boolean> {
const provided = url.searchParams.get("token");
if (!provided || !env.UPLOAD_TOKEN) {
return false;
}
return await constantTimeEquals(provided, env.UPLOAD_TOKEN);
}
/**
* Constant-time string comparison. Both inputs are SHA-256 hashed first so the
* comparison runs over equal-length buffers and the secret's length is not leaked
* via timing. Uses only Web Crypto primitives present in both Workers and Node.
*/
async function constantTimeEquals(a: string, b: string): Promise<boolean> {
const encoder = new TextEncoder();
const [hashA, hashB] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(a)),
crypto.subtle.digest("SHA-256", encoder.encode(b)),
]);
const viewA = new Uint8Array(hashA);
const viewB = new Uint8Array(hashB);
let diff = 0;
for (let i = 0; i < viewA.length; i++) {
diff |= viewA[i]! ^ viewB[i]!;
}
return diff === 0;
}
function generateKey(contentType: string): string {
const now = new Date();
const stamp =
`${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}` +
`-${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}-UTC`;
return `${stamp}-${randomSlug()}.${extensionFor(contentType)}`;
}
/** Cryptographically-random base62 slug, unbiased via rejection sampling. */
function randomSlug(): string {
let slug = "";
while (slug.length < SLUG_LENGTH) {
const bytes = crypto.getRandomValues(new Uint8Array(SLUG_LENGTH - slug.length));
for (const b of bytes) {
if (b < 248) {
// reject 248..255 so (b % 62) stays uniform
slug += SLUG_ALPHABET[b % 62];
if (slug.length === SLUG_LENGTH) break;
}
}
}
return slug;
}
/** Keep the stored content-type to a known image set; anything else is treated as opaque bytes. */
function normalizeContentType(rawType: string): string {
const type = rawType.split(";")[0]?.trim().toLowerCase() ?? "";
return type in IMAGE_EXTENSIONS ? type : "application/octet-stream";
}
function extensionFor(contentType: string): string {
return IMAGE_EXTENSIONS[contentType] ?? "bin";
}
function parseMaxBytes(env: Env): number {
const configured = Number(env.MAX_UPLOAD_BYTES);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_UPLOAD_BYTES;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
function text(body: string, status: number, extraHeaders?: Record<string, string>): Response {
return new Response(body, {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
});
}
Two decisions in there that are easy to get wrong:
handleUploadchecks the declaredContent-Lengthfirst, then re-checks the real size after parsing the form, because the header is a hint and not a guarantee.handleServerefuses an empty key, which is what stops the collection root (/screenshots/) from ever behaving like a listing.
3. Configure Wrangler
wrangler.jsonc, with the R2 binding and the route on your domain. I disable the workers.dev URL so the only way in is the route, which keeps the exposed surface small.
{
"name": "screenshots",
"main": "src/worker.ts",
"compatibility_date": "2026-06-06",
"workers_dev": false,
"routes": [
{ "pattern": "yourdomain.com/screenshots/*", "zone_name": "yourdomain.com" }
],
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "screenshots" }
],
"observability": { "enabled": true, "head_sampling_rate": 1 }
}
4. Generate and set the upload token
Generate a long random secret, keep a copy somewhere safe (a password manager), and set it as a Worker secret. It never goes into your repo.
openssl rand -base64 32 # copy the output, this is your <TOKEN>
wrangler secret put UPLOAD_TOKEN # paste it when prompted
wrangler deploy preserves existing secrets, so you only do this once.
5. Deploy
wrangler deploy
Confirm both paths work before touching Shottr. The serve path should 404 cleanly on a made-up key, and the upload path should reject a missing token:
# 401, no token
curl -i -X POST 'https://yourdomain.com/screenshots/'
# round-trip a real upload, then fetch what it returns
curl -X POST 'https://yourdomain.com/screenshots/?token=<TOKEN>' \
-F 'file=@/path/to/test.png'
# -> SUCCESS: https://yourdomain.com/screenshots/2026-06-06-...-UTC-ab12cd34.png
6. Point Shottr at it
This is the step that has no UI, so do it carefully.
-
Open Shottr -> Preferences -> Uploading and set the mode to Shottr Cloud. Put any value in its token field (the Worker ignores it, see gotcha #4); Cloud mode just has to be selected for the endpoint override to hook into.
-
Override the endpoint from the terminal, substituting your domain and token:
defaults write cc.ffitch.shottr uploadEndpoint 'https://yourdomain.com/screenshots/?token=<TOKEN>' -
Restart Shottr so it picks up the changed default.
7. Capture and upload
Take a screenshot, then press the upload hotkey (Cmd-E). Shottr POSTs the image to your Worker as a multipart file, the Worker stores it under a ...-UTC-<slug>.png key, returns SUCCESS: <url>, and Shottr copies that public URL to your clipboard. Paste it anywhere. It is on your domain, it points at exactly one image, and nobody can walk it to find the others.
What you end up with
A capture-to-clipboard-link flow that is indistinguishable from Shottr Cloud, except the files sit in your own R2 bucket behind your own domain. The link you paste is a clean a-hafez.com/screenshots/... URL instead of a long pub-….r2.dev or s3.amazonaws.com one, it gives away nothing about your other screenshots, and the running cost is effectively zero at personal volume. The only piece you cannot avoid paying for is the Shottr Friends Club license, which is what unlocks the upload mode the whole thing is built on.