How to stand the cloud up on a machine you rent, in about an hour, and what it costs. Written by S1
on 2026-09-04 from a run against the compose unit on the operator’s machine. Every claim carries one of
three tags, the same discipline the market lane uses for product claims:
- [MEASURED] — done in this session and the number is in
worklog/server.md(internal:docs/90-meta/worklog/server.md). - [BUILT] — the code path exists and is tested, but this exact configuration was not run here.
- [INTENT] — reasoned, not run. A VPS was not rented for this session (the owner buys, not a lane).
The unit is cloud/docker-compose.yml (internal: cloud/docker-compose.yml): Postgres + Redis + MinIO + the
service, built from cloud/Dockerfile (internal: cloud/Dockerfile). One file serves dev, CI and the VPS.
0. What the server can and cannot see — read this before renting anything
The product’s promise is zero-knowledge custody (ADR-0014, ADR-0019): the server stores what it needs to coordinate and cannot read a profile. That was re-measured, not re-read, on 2026-09-04 by querying the database and listing the bucket after a full two-device round trip [MEASURED]:
| where | in the clear | sealed / hashed (server cannot read) |
|---|---|---|
accounts |
email · totp_enabled flag · created-at (S10: for an unconfirmed address, the time of its LATEST registration — a replacing registration restarts the 24-hour clock) · a 16-byte auth salt (non-secret by design) · verified_at (S8: when the address was confirmed by the mailed code; NULL = registered, not yet confirmed — cannot sign in, removed 24 h after its latest registration) · email_code_failures (the wrong-code budget, 0–5) · auth_kdf (S10, ADR-0040: the derivation version of the stored verifier — NULL = legacy, auth-v1; a label written with the verifier and announced at login-init, telling the client how to derive, never letting the server derive anything) |
password_hash is an Argon2id hash of a client-derived verifier, never the passphrase (ADR-0019 C1) · totp_seed_enc 60 bytes, AEAD under VFLIN_TOTP_KEY |
plans (S12) |
the owner’s price list as DATA: code, monthly price, the ceilings (NULL = none), whether it is still sold. Not about any account; it is here because editing a price must not be a release | — |
email_codes (S8) |
relay_response + relayed_at (S12, ISSUE-204: what the mail relay answered when it took the letter — a status line and, at most relays, a queue id. About the ENVELOPE: it never contains the code, and it is what the owner hands the provider when a letter goes missing) · which account a code is for, its purpose (verify — activates the account; device — admits one new device, bound to that device’s hash), expiry (15 min), whether it was used — and an HMAC under the server secret of the 6-digit code (code_hash), never the code; a row with a NULL account is a decoy an unknown or already-confirmed address leaves at resend-code, so both branches do the same work |
the code itself (mailed once, single-attempt, 15 min) |
devices |
device id (the daemon’s UUID) · device name · last-seen | refresh_token_hash (43 chars; the token itself is never stored) · secret_hash (S9, 43 chars: the SHA-256 of the device secret the last sign-in handed that device — what makes it “known”; replaced by every sign-in, gone with device.revoke, NULL until the device next signs in with a mailed code; the secret itself is never stored) |
profiles_meta |
profile id · blob key · content hash · ciphertext size · wallets (S12: how many wallets the profile holds — a number the CLIENT declares at push/commit, never one the server counts; it is what the plan’s wallet ceiling is checked against, so the ceiling bounds an honest client and not a modified one, and the contract says so) · version vector (device ids → counters) · fence (S5: the sync-lease fence of the last accepted push, a monotonic counter — coordination metadata, derived from no plaintext) · state · updated-at |
— |
account_keyrings |
a JSON envelope: version, the KDF parameters (mCost/tCost/pCost), saltKek, the device-key registry (device id → 32-byte public signing key), accountPub |
wrappedMkPassword, wrappedMkRecovery, wrappedAccountPriv (AEAD nonce + ciphertext) · the MK-MAC |
bucket vflin-blobs |
per blob a cleartext manifest (VFB1 + JSON): profile id · device id · version vector · coreVersion · plaintext payloadSize · inner content hash — documented in PROFILE_STORAGE_SYNC.md §“manifest (cleartext, integrity-protected)” |
wrappedDek · sealedRecord (name / fingerprint / proxy) · the payload — AEAD, opaque bytes |
audit_log, orgs, shares, subscriptions |
empty after the flow: the daemon never ships action-log entries (log.append is unreachable from it), no team was created, no plan bought |
— |
password_resets (S4) |
which account a reset is for, its expiry, whether it was used — and a hash of the mailed token (token_hash), never the token; a row with a NULL account is a decoy an unknown email leaves so reset-init does the same work on both branches |
the reset token itself (mailed once, 30-min, single-use) |
billing_events (S4) |
the account an event was for, its plan/status, the provider’s order reference, when it arrived — a payment-history read for the cabinet’s invoices | — |
So an operator with root on the VPS learns: who has an account, how many devices and profiles they have, how large each profile is, which Chromium version last opened it, and when it changed. They learn no passphrase, no profile content, no cookies, no fingerprint, no proxy. Say exactly this to users; do not say “the server stores nothing”.
1. What to rent
| need | value | why |
|---|---|---|
| OS | Ubuntu 24.04 LTS | Docker’s own apt repo targets it; the steps below assume apt |
| RAM | 2 GB | the unit idles at ≈ 165 MiB [MEASURED]: service 8.7 MiB, Postgres 60 MiB, Redis 6 MiB, MinIO 89 MiB. 1 GB would also run it; 2 GB leaves room for the image build (Go compiles inside Docker) |
| CPU | 1 vCPU | the service is I/O-bound; Argon2 runs on the client (the verifier is derived on the device) |
| Disk | 20 GB+ | image 35 MB, base images ≈ 900 MB, then profile blobs — see §7 on growth |
| Network | a public IPv4, ports 80/443 open | Caddy needs 80 for the ACME challenge, 443 for TLS |
| DNS | two A records: api.<domain> and s3.<domain> |
the daemon uploads blobs straight to the object store via presigned URLs, so the store needs its own public name (§4) |
Cost orientation (list prices read on 2026-09-04 from two comparison pages; not a recommendation — the owner picks the provider and decides on crypto payment):
| provider | 2 GB plan | crypto |
|---|---|---|
| Hetzner Cloud | ≈ $3.60 / mo (1 vCPU, 25 GB NVMe) | not stated on the page read |
| Cockbox | ≈ $3 / mo | Bitcoin, Monero |
| UltaHost | ≈ $5.50 / mo | Bitcoin, USDT, ETH |
| Vultr / DigitalOcean | ≈ $6 / mo (1 vCPU, 50–55 GB) | not stated on the page read |
| Shinjiru | ≈ $7 / mo | Bitcoin |
| Njalla | ≈ $15 / mo | Bitcoin, Monero |
Budget $4–7 / month for the box plus a domain. There is no other recurring cost in this design: no
managed database, no CDN, no object-storage bill (MinIO on the same disk). Cloudflare R2 instead of MinIO
is supported by env (VFLIN_S3_ENDPOINT + VFLIN_S3_USE_SSL=1 + VFLIN_S3_REGION=auto) [BUILT] but
was not run in this session.
2. Base system (once) — [INTENT]
# as root on the fresh VPS
apt-get update && apt-get -y upgrade
curl -fsSL https://get.docker.com | sh # Docker Engine + the compose plugin
apt-get -y install caddy git ufw
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable
adduser --disabled-password --gecos "" vflin && usermod -aG docker vflin
Nothing else needs to be installed: Go compiles inside the image build, so the VPS never gets a Go
toolchain. docker compose version must print v2.x (the get.docker.com script installs it).
3. Get the code and generate the secrets — [MEASURED locally]
Only cloud/ is needed on the VPS — it is the Docker build context. Clone the repository (a read-only
deploy key on the private remote) or copy that one directory.
su - vflin
git clone <the repository> vflin && cd vflin
VFLIN_S3_PUBLIC_ENDPOINT=s3.example.com VFLIN_S3_PUBLIC_USE_SSL=1 \
VFLIN_PUBLIC_URL=https://api.example.com VFLIN_TRUST_PROXY=xff \
sh cloud/scripts/gen-secrets.sh
That writes cloud/.env with fresh random keys (it refuses to overwrite an existing one — measured:
second run exits 2). Replace example.com with your domain. The three variables in front of it are the
only difference between a laptop and a VPS:
| variable | laptop | VPS | what it does |
|---|---|---|---|
VFLIN_S3_PUBLIC_ENDPOINT |
127.0.0.1:9000 (default) |
s3.<domain> |
the host presigned upload/download URLs are signed for. Wrong value ⇒ every push fails on the client with an unreachable host, while the server logs nothing wrong. Measured 2026-09-04: before this knob existed the unit signed http://minio:9000/…, which no daemon outside the container can resolve |
VFLIN_S3_PUBLIC_USE_SSL |
empty | 1 |
those URLs are https:// |
VFLIN_PUBLIC_URL |
empty | https://api.<domain> |
payment-provider webhook callbacks (unused until billing is configured) |
VFLIN_TRUST_PROXY |
empty | xff behind Caddy/nginx/Tailscale Serve; 1 or cloudflare behind Cloudflare |
rate-limit by the client IP the proxy forwards, not by 127.0.0.1. xff reads the rightmost X-Forwarded-For (the hop the proxy appended) and IGNORES CF-Connecting-IP, which such a proxy passes through from the client untouched (Codex 130959Z #1). cloudflare (1 — the value the live .env carries, §11.5 (internal: our production’s record in docs/50-cloud/DEPLOY.md, not published)) reads CF-Connecting-IP first, because Cloudflare’s edge writes it itself |
VFLIN_BLOB_MAX_UPLOAD_BYTES |
empty | 104857600 behind Cloudflare Free |
the largest single PUT the public object-store path carries; blob.upload-url answers a bigger size with 413 and details.multipart, and the client uploads in parts via blob.upload-init (§11.6 (internal: our production’s record in docs/50-cloud/DEPLOY.md, not published)) |
Back up cloud/.env the moment it exists (scp it off the box, or into a password manager). It is
the only copy of the keys; §8 says what losing each one costs. Never commit it — it is git-ignored and
scripts/check-no-secrets.sh runs before staging.
4. Bring the unit up — [MEASURED locally]
docker compose -f cloud/docker-compose.yml up -d --build --wait
curl -s http://127.0.0.1:8080/v1/health
Expected: {"ok":true,"data":{"status":"ok","apiVersion":"0.0.1","datastores":{"postgres":true,"redis":true,"objectStore":true}}}.
On the operator’s machine the first up took 62 s including the image build; a rebuild of the
service alone took 88 s; a from-scratch run of these exact steps in a clean directory is timed in
worklog/server.md (internal: docs/90-meta/worklog/server.md). The service logs one JSON line per request and,
at boot, one line per subsystem — read them once:
docker compose -f cloud/docker-compose.yml logs cloud | grep -v '/v1/health'
A healthy boot prints store: connected + migrated, sync locks: Redis backend, rate limits: Redis (shared) backend, blob custody: presigned URLs are issued for host=https://s3.<domain>, and exactly
one WARN — no payment provider configured — which is correct until billing is set up. Any other WARN
names an unset variable; fix it before opening the firewall.
Everything binds to 127.0.0.1 (VFLIN_BIND in .env). The datastores are never reachable from outside
the machine. Only Caddy is.
5. TLS with Caddy — [INTENT]
Caddy obtains and renews Let’s Encrypt certificates by itself once DNS points at the box. Two sites: the API, and the object store the daemons upload to directly.
# /etc/caddy/Caddyfile
api.example.com {
reverse_proxy 127.0.0.1:8080
}
s3.example.com {
reverse_proxy 127.0.0.1:9000
}
systemctl reload caddy
curl -s https://api.example.com/v1/health
Why this works and where it can break, so it is checkable rather than trusted: a presigned S3 URL carries
a SigV4 signature over the Host header. The service signs for s3.example.com (§3), the daemon sends the
request there, Caddy forwards it keeping the original Host (its reverse_proxy default), MinIO
verifies the signature against that host. TLS is terminated at Caddy; the scheme is not part of the
signature. If uploads fail with 403 SignatureDoesNotMatch, the Host reaching MinIO differs from the
signed one — check that nothing rewrites it. If you own a certificate instead, replace the site body
with tls /etc/ssl/your.crt /etc/ssl/your.key on each site.
The MinIO console (port 9001) is deliberately not published. Reach it over an SSH tunnel if you need
it: ssh -L 9001:127.0.0.1:9001 vflin@<vps>.
6. Prove it from a client — [MEASURED locally, against http://127.0.0.1:8080]
The daemon reads the cloud address from its environment. On the client machine:
VFLIN_CLOUD_URL=https://api.example.com vflind # or set it for the installed daemon
vflin keyring setup --password <passphrase> # once per machine, if not done
vflin session unlock --password <passphrase>
vflin cloud register --email you@example.com --password <cloud-password> --device-name laptop
# S8 (2026-09-10): the account is NOT active until the 6-digit code mailed to that address is entered —
# `auth.verify-email {email, code}`; the daemon's `vflin cloud verify-email --code …` is A93's (until it lands:
# the cabinet's verify form (W6) or a curl to /v1/auth/verify-email). Then `vflin cloud login` from this
# device needs no further code — the registering device is already known to the account.
vflin keyring publish # so a second device can import the keyring
vflin profile create --name first && vflin profile start <id> --headless && vflin profile stop <id>
vflin cloud push <id>
On a second machine: cloud login → keyring import --password <passphrase> → session unlock →
cloud onboard <id> → profile start <id>. The whole sequence, 26 CLI steps across two isolated daemons,
ran green in 222 s on 2026-09-04; the step table with codes and timings is in the worklog.
Two things the product does not yet let a user do from the CLI or the GUI, and the server does:
enrol 2FA (twofa.enroll/activate/disable — measured green with curl: login without a code → 401 TOTP_REQUIRED, wrong code → 401 TOTP_INVALID, right code → 200), and list or revoke devices. These are
control-plane work (cloud.twofa.*, cloud.devices.* daemon methods), tracked on the board.
7. Backups — [INTENT; the commands are standard]
Three things hold state: the Postgres volume, the MinIO volume, and cloud/.env. Back up all three; a
database without the bucket is a list of profiles nobody can download, and either without .env is
readable but no device can log in (§8).
#!/bin/sh
# /home/vflin/backup.sh — run from cron daily; keeps 14 days
set -eu
cd /home/vflin/vflin
D=/home/vflin/backups; mkdir -p "$D"; T=$(date -u +%Y%m%dT%H%M%SZ)
docker compose -f cloud/docker-compose.yml exec -T postgres pg_dump -U vflin -Fc vflin > "$D/pg-$T.dump"
docker run --rm -v vflin-cloud_miniodata:/data:ro -v "$D":/backup alpine \
tar czf "/backup/minio-$T.tgz" -C /data .
cp cloud/.env "$D/env-$T"
find "$D" -type f -mtime +14 -delete
# crontab -e (as vflin)
15 3 * * * /home/vflin/backup.sh
Copy backups/ off the machine (rsync to another box, or rclone to any bucket). Restore, on a fresh
box after §2–§4 with the SAME .env:
docker compose -f cloud/docker-compose.yml stop cloud
docker compose -f cloud/docker-compose.yml exec -T postgres pg_restore -U vflin -d vflin --clean --if-exists < pg-<T>.dump
docker run --rm -v vflin-cloud_miniodata:/data -v "$PWD":/backup alpine sh -c 'cd /data && tar xzf /backup/minio-<T>.tgz'
docker compose -f cloud/docker-compose.yml start cloud
The bucket is a plain directory tree under /data; MinIO reads whatever is there. Both backups are
ciphertext plus metadata (§0) — safe to store anywhere, useless to anyone without the users’ keys.
Disk growth, measured and not yet fixed: after two pushes of one profile the bucket held both versions (45 KiB and 47 KiB) — a commit does not delete the superseded object, and the bucket has no lifecycle rule although the server’s comments say staged leftovers are “swept by lifecycle GC”. Until a sweep exists, bucket size grows by one blob per push. Filed as a residual in the worklog.
8. Updating, rolling back, and losing a secret
Update — [BUILT]:
git pull && VFLIN_COMMIT=$(git rev-parse HEAD) docker compose -f cloud/docker-compose.yml up -d --build --wait
VFLIN_COMMIT (since S69) is the commit the image says it was built from — the binary prints it on its first log
line (vflin-cloud listening … "commit") and the image carries it as org.opencontainers.image.revision. Built
without it, the start warns and says unknown. Before it, the only way to tell what production ran was to rebuild a
candidate commit and compare the binary byte for byte (§18.28 (internal: our production’s record in docs/50-cloud/DEPLOY.md, not published)).
Migrations apply at boot (store: connected + migrated); all eleven so far are CREATE … IF NOT EXISTS /
ADD COLUMN IF NOT EXISTS, i.e. additive, so rolling back the binary needs no database rollback:
git checkout <previous> && docker compose … up -d --build --wait. Check that property again the day a
migration drops or renames something. Downtime is the container swap, a few seconds; there is one
instance, so there is no zero-downtime deploy — and no need for one at alpha.
Losing a secret — what each one protects is in the header of
gen-secrets.sh (internal: cloud/scripts/gen-secrets.sh); what to do:
| lost | consequence | recovery |
|---|---|---|
VFLIN_JWT_SEED |
every access and refresh token becomes invalid | generate a new seed, restart cloud; every device runs vflin cloud login again. Nothing stored is lost |
VFLIN_TOTP_KEY |
every enrolled TOTP seed is unreadable ⇒ every account with 2FA on cannot log in (the server requires a code it can no longer verify) | there is no backdoor, by design. After verifying the user out of band, an operator with DB access clears the factor and the user re-enrols: UPDATE accounts SET totp_enabled=false, totp_seed_enc=NULL WHERE email='…'; then generate a new key and restart |
VFLIN_LOGIN_INIT_SECRET |
decoy salts for unknown emails change; every mailed sign-up / new-device code still in flight stops verifying (S8: the codes are stored as HMACs under this secret, 15-minute lives); and since S67 every unused recovery code of two-step sign-in is dead — so the start REFUSES a new secret while such codes exist and prints how many (§18.27 (internal: our production’s record in docs/50-cloud/DEPLOY.md, not published), follow-up 2) |
put the old secret back if you have it. If not: generate a new one and start ONCE with VFLIN_RECOVERY_CODES_VOID=<the value the refusal printed>, then remove it; the people with two-step sign-in on issue a new set in the cabinet with their authenticator. Anyone mid-registration asks for a fresh code (auth.resend-code) |
VFLIN_PG_PASSWORD |
the service cannot connect | the value in .env is applied only on the volume’s first init; to rotate: ALTER ROLE vflin PASSWORD '<new>'; via psql, update .env, restart cloud |
VFLIN_S3_SECRET_KEY |
the service cannot sign or stat blobs | MinIO’s root credentials are its env: change the value in .env, up -d recreates minio with the new root password (data stays), restart cloud |
| a user’s keyring passphrase and recovery key | that user’s profiles are unrecoverable | none. The server never had them (§0). Tell users this at signup |
9. What answers 503 or is unreachable until configured — [MEASURED, all 40 contract routes]
Probed with a valid token against a container that had only the database configured:
| without | routes answering 503 UNAVAILABLE |
|---|---|
VFLIN_LOGIN_INIT_SECRET |
auth.login-init — so no device can log in (register still works). Fail-closed on purpose: an empty HMAC key would make the decoy salt guessable and the endpoint an account-enumeration oracle |
VFLIN_TOTP_KEY |
twofa.enroll, twofa.activate, twofa.disable |
VFLIN_S3_ENDPOINT |
blob.uploadUrl, blob.commit, blob.downloadUrl, blob.head, sync.push |
| a payment provider | billing.checkout (503); billing.webhook answers 404 |
| Redis | nothing — sync locks and the rate limiter fall back to Postgres and to an in-process bucket |
Everything else answered with its real code (200, 400, 401, 404, 409). With the full .env from §3 no
route answers 503. Ten of the forty routes are implemented on the server but no daemon method calls
them: twofa.* (3), device.list/device.revoke, log.append/log.list, billing.checkout,
billing.webhook (server-only by nature), cloud.health (the daemon never asks whether the cloud is up
— vflin cloud status reports only the local session).
10. Not covered here
Single node, no failover, no monitoring beyond docker compose ps and the health route, no log
shipping, no rate-limit tuning, no R2. All of that is post-alpha; the design permits it (stateless
service, Redis-shared limiter, S3-compatible custody) and none of it was run.
Two things that are not in the steps above and cost us days each. They are general, not ours — which is why they are here and not in the operator sections, where the rest of our own setup lives.
- A CDN or reverse proxy in front of the object store caps a single upload. Behind Cloudflare one
PUTis at most 100 MiB, and a profile can be larger, so the cloud splits it into parts. If you put anything in front of your bucket, find that cap before a user meets it: the failure arrives as a refused upload of a profile that was fine yesterday and is simply bigger today. 250 OKfrom a mail relay does not mean the letter arrived. The relay accepted it; the recipient’s side may still drop it silently, and Gmail does exactly that when the sending domain has no DMARC record. Sign-up codes then vanish with no error anywhere in your logs. Set SPF and DMARC for the sending domain before you trust a sign-up flow, and test against a real mailbox at a big provider, not only your own.