DaemonClient
A private, unlimited, encrypted photo and file cloud that runs entirely on free tiers you control — $0/month, no server, no hardware. This page covers how it works and how to run it yourself.
Cloud storage is expensive and you don't own any of it. DaemonClient combines two services with unusually generous free tiers — Telegram (unlimited file storage through bots and channels) and Cloudflare Workers (serverless compute plus a D1 database) — so that every user gets their own isolated stack: their own bot and private channel, their own worker and database, their own encryption keys.
The promise
A self-hosted install depends on nothing we run — not your files, not your credentials, not a telemetry ping. A self-hosted build of every web app is verified at build time to contain no operator address. If this project vanished tomorrow, your install keeps working: the files are in your Telegram channel, the index is in your D1 database, and the code is in your clone under AGPL-3.0.
How it works
One vertical data path per user: your device → your worker → your Telegram channel. There is no shared file server and no operator database holding your data.
That diagram is true, and it hides the most interesting part. On the web, file bytes never touch the worker at all. The browser chunks and encrypts a file itself and sends it straight to Telegram; a service worker reads it back the same way, fetching only the chunks a given byte range needs. The worker sees the index and nothing else.
This is why a photo library fits in a free tier that allows 100,000 worker requests a day: thumbnails and downloads cost a Telegram request, not a worker one.
The storage model
Telegram's Bot API will not let a bot download a file larger than 20 MB. Uploads can be bigger, which is a trap — you can happily send a 60 MB document and then discover you can never fetch it back. So everything is split at 19 MB of plaintext, and chunks are never merged.
A single photo becomes up to four stored artefacts, which surprises people:
| Artefact | Where | Why |
|---|---|---|
thumbhash | a column in D1 | ~25 bytes. The blurred placeholder the grid paints instantly, before any network request. |
| thumbnail | one Telegram message | The small thumbnail Telegram generates for you, free. |
| preview | one Telegram message | A larger preview for the detail view. |
| chunks | N Telegram messages | The actual file, recorded as a list of file_ids. |
The reason for the first three is CPU, not storage: generating a thumbnail from a 40 MB original inside a worker is not possible on the free plan. So either the browser does it before upload, or Telegram does it as a side effect of accepting the file.
Deduplication Every asset carries
base64(SHA-1(plaintext)), computed by the worker when the client sends none. Without it the
"do you already have these?" check never matches and an app re-uploads your entire library every time it
restarts.
Encryption
Three modes, protecting against different things. Being clear about which is which matters more than the marketing line.
| Mode | Where the key lives | Who can read plaintext | Used by |
|---|---|---|---|
| Client-side | derived in your browser, never transmitted | only your browser | Drive, and web uploads to Photos |
| Server-side | in your own D1 database | your own worker, transiently | mobile uploads to Photos |
| Off | — | anyone with the channel | never, by default |
AES-256-GCM throughout, with keys derived by PBKDF2-SHA256 at 100,000 iterations and a fresh 12-byte IV per chunk.
Server-side mode is a deliberate trade. It is what lets the worker compute a checksum, extract EXIF and generate a thumbnail — things a zero-knowledge server cannot do by definition. The mitigation is that "the server" is a worker serving exactly one person, on that person's own Cloudflare account. On a self-hosted install it is not our machine in any sense.
Fails closed An install configured to encrypt but missing its key material refuses uploads rather than silently storing plaintext. This was not always true — installs created before 27 July 2026 should read the migration note in the repository's self-hosting guide.
Accounts & sign-in
One install, one owner. The install is claimed by the first sign-in that goes through the worker's own password login; every authenticated route is gated on that owner, so a second account in your Firebase project cannot read your data. The check lives at the single authentication chokepoint rather than being repeated per route, so a route added later is covered by default.
Two credential shapes are accepted, told apart by counting dots: a Firebase ID token
(three parts, verified RS256 against Google's public certs with aud, iss and
exp pinned) or a session token (two parts,
base64(payload).hmac, signed with that install's own secret so a session minted for one
worker does not verify on another).
On the hosted service, one sign-in serves all three apps: a cookie on the parent domain lets Photos and Drive ask for a fresh ID token and trade it for their own session. The refresh token stays in an HttpOnly cookie and never crosses into JavaScript. A self-hosted install skips all of this and signs in against its own Firebase project directly.
The limits behind the design
Almost everything that looks over-engineered is one of these. Figures are the Cloudflare Workers free plan.
| Limit | Value | What it forces |
|---|---|---|
| CPU time | 10 ms / request | No image decoding, no transcoding, no large buffers. HEIC goes to a separate function. |
| Memory | 128 MB | Responses stream; never more than about two chunks held at once. |
| Subrequests | 50 external / request | A 50-chunk file is already at the ceiling, so background repair jobs run one per request. |
| Requests | 100,000 / day | Why bytes go browser↔Telegram directly. |
| Request body | 100 MB | Why a >100 MB video can't be uploaded from the mobile app. |
| Telegram download | 20 MB | Where 19 MB chunking comes from. |
Error 1102 Exceeding CPU or memory doesn't degrade — Cloudflare kills the invocation and returns an error page with no CORS headers, so the browser reports a CORS failure. If you're staring at an inexplicable CORS error on a media request, check for 1102 first.
What's in the repository
| Directory | What it is |
|---|---|
immich-api-shim/ | The per-user worker — the entire Photos + Drive API. Deployed once per user. |
selfhost/ | The self-hosting CLI. Dependency-free, runs from a fresh clone. |
deployment-service/ | Provisions workers for the hosted service. Not used when self-hosting. |
accounts-portal/ | Sign-up, the setup wizard, the dashboard. |
immich/web/ | The Photos app — a fork of Immich's frontend. The upstream server is not used at all. |
drive/ | The Drive app. Not a fork. |
processor/ | Optional HEIC thumbnailer, deployed to your own Vercel. |
schema/ | The database schema, defined once for both provisioners. |
Every directory has its own README. The full technical write-up is docs/ARCHITECTURE.md.
Self-host it
Run the whole thing on accounts you own. About ten minutes, most of it clicking around in Telegram and Cloudflare.
What you need
Telegram
A bot and a private channel. Stores your files.
Cloudflare
Workers + D1 on the free plan. Runs the API.
Firebase
Your own project. Your sign-in and accounts.
Node 18+
On the machine you set up from. Runs the guided script.
Optional, also free: a small HEIC processor on Vercel that turns iPhone photos into grid thumbnails, and Firebase Hosting to serve the web apps. Both are covered below.
Quick start
The whole thing is one guided command. It asks for each credential in turn, tells you exactly where to get it, checks it against the real service before moving on, and saves after every step — stop any time and re-run to resume.
git clone https://github.com/myrosama/DaemonClient.git
cd DaemonClient/immich-api-shim && npm install && cd ..
node selfhost/bin/daemonclient.mjs setup
About ten minutes, most of which is you clicking around in Telegram and Cloudflare. When it finishes
you get your API address, e.g. https://daemonclient-a1b2c3.yourname.workers.dev — that
address is your server.
Tip Run commands as
node selfhost/bin/daemonclient.mjs <command>, or npm link once inside
selfhost/ to get the shorter daemonclient command used throughout these docs.
Setup, step by step
Everything below is what daemonclient setup walks you through. Read it if you want to
understand each credential — or just run the command and follow along.
Telegram — where your files live stores files
Create the bot. Message @BotFather, send
/newbot, answer two questions, copy the token (looks like12345:AAH…).Create the channel. Make a new private channel, add your bot as an administrator with permission to post, edit and delete messages. Forward any message from it to @userinfobot to get the channel id (
-1001234567890).Note Setup posts a message to the channel and deletes it, because a bot can be a member of a channel and still be unable to write to it — better to find out now than on your first upload.
Cloudflare — where the API runs runs the API
At dash.cloudflare.com/profile/api-tokens, create a Custom Token with exactly these permissions:
Scope Permission Account · Workers Scripts Edit Account · D1 Edit Account · Account Settings Read Do not use a Global API Key. The custom token can create your worker and database and nothing else. The setup stores it locally (mode 0600), never sends it anywhere but Cloudflare, and it never leaves your machine.
Firebase — your sign-in
At console.firebase.google.com, add a project (Analytics not needed). Then:
- Build → Authentication → Get started → enable Email/Password.
- Authentication → Users → Add user — this is your login.
- Project settings → General → Your apps → register a Web app, copy the
apiKeyandprojectId.
Setup signs in for real to check the details, so a typo or a still-disabled Email/Password provider fails here rather than on your phone later.
There is no signup page. Accounts exist only in your own Firebase project. Firebase self-registration is open by default and your apps ship a Firebase Web API key in the browser like every Firebase app, so a stranger can register — what stops them reaching your files is the worker's owner gate: one install belongs to one account, and every authenticated route is checked against it. Turn off self-registration in the Firebase console (Authentication → Settings) if you would rather they could not.
Deploy the worker & encryption keys
Setup builds the worker from your checkout, deploys it to your Cloudflare account bound to your D1, seeds your Telegram settings, and generates your encryption keys. The keys are generated once and reused on every later deploy — a new key would make files already in Telegram unreadable, and a new session secret would sign you out.
Fail-closed If encryption is enabled but the key material is missing, the worker refuses the upload rather than silently storing plaintext. An install that skips key generation fails loudly instead of leaking.
HEIC processor optional
Telegram makes a thumbnail for every format except HEIC, which it cannot decode. So iPhone photos need a tiny serverless function — on your own free Vercel account — to turn them into grid thumbnails. Skip it and everything still works; HEIC photos just show no grid thumbnail.
cd processor npx vercel deploy --prodThen set two environment variables on the Vercel project and redeploy:
Variable Value FIREBASE_PROJECT_IDyour Firebase project — required; the processor rejects every request while it is unset OWNER_UIDyour user id ( daemonclient processorprints it) — pins the instance to your accountConnect it with
daemonclient processorand paste the URL. It runs on Vercel's Node runtime (the libheif WASM is too heavy for the Edge runtime).Privacy Plaintext HEIC bytes are sent to the processor for conversion, so it must be your own instance, pinned to your
OWNER_UID. A leaked URL is useless to anyone else, and the bytes never touch operator infrastructure.The web apps — one command
The web side is three apps: the dashboard (your main page — sign in, then open Photos or Drive), Photos, and Drive. One command builds all three from your checkout, each pointed at your worker and your Firebase, and deploys them to Firebase Hosting on your own project (free, serverless):
daemonclient webIt builds, creates three Firebase Hosting sites, deploys, and adds their addresses to your worker's
ALLOWED_ORIGINS. Firebase Hosting signs in with your own Google account (firebase loginopens a browser — nothing reaches us). You end up with:https://<project>.web.app ← dashboard (open this one) https://<project>-photos.web.app ← Photos https://<project>-drive.web.app ← DrivePrefer to build them yourself? Each app takes its worker URL from a build-time variable and defaults to the operator host only when not building for self-host, so a self-host build never points at us:
App Build with Photos PUBLIC_SELF_HOST=1 PUBLIC_DAEMONCLIENT_WORKER_URL=<worker> npm run buildDrive VITE_SELF_HOST=1 VITE_API_BASE=<worker> npm run buildDashboard VITE_SELF_HOST=1 VITE_API_BASE=<worker> VITE_FIREBASE_*=… npx vite build --mode selfhostThe mobile app needs no build — install DaemonClient, enter your API address as the server URL, and sign in.
Day-to-day commands
| Command | What it does |
|---|---|
daemonclient setup | Create your cloud: Telegram, Cloudflare, account, deploy. |
daemonclient web | Build & deploy the three web apps to your Firebase. |
daemonclient status | What is running, and is it healthy. |
daemonclient update | Rebuild from the current source and redeploy. |
daemonclient processor | Add or change the HEIC processor. |
daemonclient doctor | Diagnose a broken install and print a redacted report. |
Updating
Your worker checks GitHub once a day for a newer release and shows a note on the dashboard. It never updates itself — that stays your decision, and your server never phones home to us. To take an update:
git pull
daemonclient update
update re-applies any new database changes, rebuilds from the source in your folder, and
redeploys. Your files, database and sign-in are untouched — it deliberately reuses your existing secrets.
Same code, both flavours The hosted service and your self-hosted install run the same codebase. A fix lands once; hosted installs are pushed to, self-hosted installs pull the release. You are never on a second-class fork.
Security model, honestly
- Drive is encrypted client-side in your browser before upload — zero-knowledge; neither the worker nor Telegram sees plaintext.
- Photos is encrypted on your own single-tenant worker before reaching Telegram — the trade-off that enables thumbnails, EXIF and dedup, confined to infrastructure that serves only you.
- One install, one owner. The install is claimed by the first sign-in that goes through the worker's own password login; every authenticated route is gated on that owner, so a second account in your Firebase project cannot read your data.
- Your bytes never transit shared machines. Heavy-compute features (HEIC) run only against a per-user processor URL from your own config — never a shared box.
Two things you must not lose
your encryption keys — which live in your D1 database as zke_password and
zke_salt, not in the state file; back them up with
daemonclient doctor --show-keys — and the Telegram channel itself.
.daemonclient-selfhost.json (mode 0600, gitignored) holds your tokens and session secret,
which is worth keeping but does not decrypt anything. Losing the key means files already stored cannot be
decrypted — there is no backdoor, by design.
Troubleshooting
Start here: daemonclient doctor — it checks every part and prints what to
run for each problem, with every secret removed so it is safe to paste into an issue.
| Symptom | Cause & fix |
|---|---|
| "Cannot reach the server" in a web app | The app's address isn't in ALLOWED_ORIGINS. Re-run daemonclient web, or add it under your worker's variables. |
| Uploads fail with 413 | Cloudflare's free plan caps request bodies at 100 MB, so single files above that can't upload from mobile. Known limitation. |
| HEIC photos have blank thumbnails | Expected without a processor. Videos and every other format are thumbnailed by Telegram, so they're unaffected. |
| Everything worked, then stopped | Check Cloudflare's free-tier limits (100k worker requests/day; D1 has its own allowances). A big library browsed hard can reach them. |
Reference
The API
Your worker is the API. It impersonates an Immich server closely enough that a stock Immich client works against it, while storing everything in your own Telegram channel.
The full contract is published two ways: docs/API.md in prose, with the callers of each route recorded, and docs/openapi.yaml as an OpenAPI 3.1 document you can load into any tool. What follows is the shape of it.
| Area | Routes | Notes |
|---|---|---|
| Auth | /api/auth/login, /logout, /status, /exchange | exchange trades a Firebase ID token for a session — this is what makes one sign-in serve all three apps. |
| Server | /api/server/config, /features, /telegram-config, /zke-config, /processor | The last three return your own secrets, deliberately: the browser needs them to read bytes from Telegram directly. Owner-gated. |
| Assets | /api/assets and its media paths | Upload, originals, thumbnails, video playback, trash, EXIF, the map. Range requests supported. |
| Manifest | /api/assets/{id}/dc-manifest | Not an Immich route. The chunk list the browser uses to bypass the worker entirely. |
| Timeline | /api/timeline/buckets, /bucket | Month buckets for the grid. Each request also dispatches exactly one background repair job. |
| Sync | /api/sync/stream | Mobile only. Newline-delimited JSON. |
| Albums, users, search | /api/albums, /api/users, /api/search | Only search/metadata is backed by real data. |
| Drive | /api/drive/*, and /dav for the mount | Separate key material from Photos. WebDAV uses HTTP Basic against a hashed token. |
| Infrastructure | /api/health, /proxy, /api/selfhost/status | /proxy relays to api.telegram.org and nothing else. |
Empty, not missing Immich features this architecture
doesn't implement — people, tags, partners, shared links, jobs, memories, stacks — return a correctly
shaped empty response rather than a 404. A client handed an error body where it expects an array
crashes on .length. So "returns 200 with nothing in it" means unimplemented, not
broken.
What doesn't work
Honesty is more useful than a feature list.
| Thing | Why |
|---|---|
| Mobile apps | A fork of the Immich app exists in the repository and is not released. It is not currently being worked on — the web comes first. |
| Videos over 100 MB from mobile | Cloudflare's request-body cap. Needs chunked upload in the app. Web uploads are unaffected, since they bypass the worker. |
| HEIC thumbnails | Blank unless you deploy the optional processor. Workers can't decode HEIC and Telegram won't thumbnail it. Every other format, video included, is fine. |
| Face recognition, smart search, places | They need ML this architecture has nowhere to run. |
| Sharing between users | Built and held pending a security review. One install, one owner, for now. |
| Multi-user installs | Not being built. Add family accounts to your own Firebase project if you want them. |
FAQ
Can other people use my server?
Only accounts in your Firebase project can sign in, and the only way to create one is you. Add family accounts that way if you want them.
Does it phone home?
No. The daily version check is an anonymous GET to GitHub's public releases endpoint, sends nothing
about your install. There is currently no switch to disable it — clearing UPDATE_REPO falls
back to a default rather than turning the check off.
Is this really free?
Yes, within the free tiers of Telegram, Cloudflare, Firebase and (if you use it) Vercel. There is no paid version of self-hosting and no key to buy.
What if this project disappears?
You keep everything. Your files are in your Telegram channel, your index in your D1 database, and the code is in your clone under an open licence. Nothing here can be switched off remotely.
Is the hosted service different from self-hosting?
Same code. Five places in the whole worker behave differently and all five are plumbing — whose Firebase to call, whose domain to put in a link, which label to report. Every feature is identical. The one real asymmetry is delivery: we can push an update to a hosted install because we hold its credentials, and we deliberately cannot push to yours.
Why Telegram? Isn't that fragile?
It is the only free unlimited store of its kind, and it is the entire premise — object storage was considered and rejected, because paying for storage would end the project. The honest trade-off: your files live in a private channel you control, subject to Telegram's terms. Keep the bot in the channel and don't delete its messages — they are your data.
Can I contribute?
Yes. Start with CONTRIBUTING.md — it covers where things live and the four hard constraints that explain most of the odd-looking code. Please don't test against anyone else's deployment, including ours; self-hosting builds you a full stack in ten minutes.
Your files. Your cloud. Your control. · github.com/myrosama/DaemonClient