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 · a 16-byte auth salt (non-secret by design) |
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 |
devices |
device id (the daemon’s UUID) · device name · last-seen | refresh_token_hash (43 chars; the token itself is never stored) |
profiles_meta |
profile id · blob key · content hash · ciphertext size · 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=1 \
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 | 1 |
rate-limit by the client IP Caddy forwards, not by 127.0.0.1 |
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
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 && docker compose -f cloud/docker-compose.yml up -d --build --wait
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 | harmless; generate a new one, restart |
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.
11. This PC as the server — S3, 2026-09-04
The owner decided on 2026-09-05 that the alpha’s server is this machine, not a rented one. That changes three things about §1–§5 and leaves the rest standing: the box is already paid for, it is behind a home router with no public address, and it reboots when Windows says so. This section is what was measured on it; the VPS path above stays valid for the day a VPS is rented.
11.0 Reachability — three ways, one chosen
| way | what is open to the internet | who holds TLS | what the owner must do | cost |
|---|---|---|---|---|
| Tailscale Serve (chosen) | nothing — only devices signed into the owner’s tailnet reach it | Tailscale issues a Let’s Encrypt cert for <machine>.<tailnet>.ts.net; renewal is theirs |
nothing new: the tailnet already exists here, six devices are on it, and this machine already runs Funnel for two other services (:443, :8443) |
$0 |
| Tailscale Funnel | the chosen ports, to anyone | same | flip Serve to Funnel on one port | $0 — but Funnel allows only 443/8443/10000, two are taken by the owner’s services, and the API and the object store need two public names. So Funnel can publish the API to the public, not the store |
| Cloudflare Tunnel | nothing (outbound tunnel), any number of hostnames under a domain | Cloudflare | a Cloudflare account, a domain on it, cloudflared tunnel login; cloudflared 2026.5.2 is installed here, no account is |
domain price |
| Port forward + DDNS + Caddy | 80/443 on the home router, forwarded here | Caddy (Let’s Encrypt), §5 | router config, a DDNS name, and accepting an open port on the home network | DDNS $0–$3/mo |
Chosen: Tailscale Serve, tailnet-only. It matches the alpha’s shape — the owner’s own devices — and it was measurable without asking the owner for anything, on the tailnet they already run. It is also the most private of the four: the sync server has no public surface at all. The moment a device that is NOT on the tailnet must reach it, the answer is Cloudflare Tunnel (two hostnames, no open ports), and the commands are in 11.5; Funnel can carry only the API.
What was done [MEASURED] — two Serve entries on ports nothing else used, the owner’s Funnel untouched:
tailscale serve --bg --https=8081 http://127.0.0.1:8080 # the API
tailscale serve --bg --https=9443 http://127.0.0.1:19000 # the object store (this machine's MinIO port)
tailscale serve status # shows both as "(tailnet only)"
# undo either: tailscale serve --https=8081 off
and in cloud/.env (then docker compose … up -d --wait cloud):
VFLIN_S3_PUBLIC_ENDPOINT=<tailnet-host>:9443
VFLIN_S3_PUBLIC_USE_SSL=1
VFLIN_TRUST_PROXY=1 # Serve forwards the client address in X-Forwarded-For
A daemon on another device then needs VFLIN_CLOUD_URL=https://<tailnet-host>:8081.
Measured from this host through tailscaled (the same path a tailnet peer takes):
| check | result |
|---|---|
https://…:8081/v1/health |
200, certificate valid (ssl_verify_result=0), 35 ms |
https://…:9443/minio/health/live |
200, certificate valid |
a fresh daemon: keyring setup → cloud register → profile create/start/stop → cloud push ×2 → cloud list, all over the tailnet name |
12 steps, all rc=0; each push 160–170 ms including the presigned PUT to https://…:9443 |
[INTENT], one line each: the same from a second tailnet device (the owner’s dcrypt is online) —
curl https://<tailnet-host>:8081/v1/health from it; the certificate chain will be the
same, the route will not go through this host’s loopback. Not done here because there is no second host in
this session.
11.1 Autostart — measured, and one flag is the owner’s
| fact | how it was read | value |
|---|---|---|
| every container’s restart policy | docker inspect … RestartPolicy |
unless-stopped ×4 — the engine brings them back whenever it starts |
the unit after docker compose down → up -d --wait |
timed, four times this week | 19–62 s, health ok |
| Docker Desktop starts with Windows | %APPDATA%\Docker\settings-store.json → AutoStart |
false — so after a reboot nothing starts until Docker Desktop is opened by hand |
| the Docker backend Windows service | sc qc com.docker.service |
DEMAND_START, stopped — Docker Desktop here is a per-user program, not a service |
So the reboot story is one flag away and the flag is in the owner’s app: Docker Desktop → Settings →
General → “Start Docker Desktop when you sign in” (it writes "AutoStart": true to the file above). It was
not flipped by this session — it is the owner’s program’s setting — and the reboot itself was not done
(the owner’s machine, their moment). [INTENT]: after the flag, shutdown /r, sign in, wait a minute,
docker compose -f cloud/docker-compose.yml ps shows four Up, and tailscale serve status still lists both
ports (Serve config survives reboots; it is stored by tailscaled).
Two honest limits of “starts with Windows” on a desktop: the containers come back only after someone
signs in (Docker Desktop runs in the user session); and Windows Update reboots the machine on its own
schedule — the sync server is down from that reboot until the next sign-in. A daemon that cannot reach the
server shows the unreadable state on the Cloud screen and nothing is lost: pushes are retried by hand,
profiles stay local.
11.2 Backups — a script, a schedule, and a restore that was actually done
cloud/scripts/backup.sh writes one dated set — pg-<T>.dump (custom format), minio-<T>.tgz (the bucket
volume, tarred read-only by a throwaway container while the store keeps serving), env-<T> (the keys) — to
VFLIN_BACKUP_DIR from .env, deletes sets older than VFLIN_BACKUP_KEEP_DAYS (14), and appends one line to
backup.log there. backup.cmd is the Windows entry point (Git Bash). Here: D:/VFlin_Project/_backups/vflin-cloud
— another disk than the OS (538 GB free against C:’s 55).
schtasks /Create /TN VflinCloudBackup /SC DAILY /ST 03:15 /TR "\"D:\VFlin_Project\Vflin Browser\cloud\scripts\backup.cmd\"" /F
schtasks /Run /TN VflinCloudBackup # prove it once
schtasks /Delete /TN VflinCloudBackup /F # undo
[MEASURED] the task was created (daily 03:15, runs as the signed-in user — Docker Desktop is reachable only
there, so “run whether user is logged on or not” would find no docker), run once through the scheduler:
Last Result 0; the set: dump 103 818 B, bucket 408 514 B, env 1 531 B; the log line landed.
Restore, proven on a clean unit [MEASURED]: cloud/scripts/restore.sh <T> s3restore <env-with-other-ports>
brought up a second unit beside the live one from the SAME keys, put the dump and the bucket in, started the
service — 33 s, health ok, 267 accounts in the restored database — and a fresh daemon pointed at it signed
in with the account S2 created the day before and listed that account’s profile (39 319 B, the stamp from
the dump). Then down -v. What this drill could not show: that an account created after the backup is
absent — every account on the box predates the set; the next drill after real use should check one.
11.3 The bucket no longer grows one blob per push — [MEASURED]
S1 found two leaks: a commit never removed the object it superseded, and the bucket had no lifecycle rule
though the code’s comments relied on one. Both closed in the server (cloud/internal/blob, api/blob.go,
api/sync.go, store/profile.go):
- the store’s two commit functions return the
blob_keythe pointer moved away from, read under the same lock/snapshot as the write; the handler removes that object after the metadata commit, never before, and never when it equals the new key (a same-content re-push overwrote it). Pinned by two tests with a fake custody that records call order — red with the sweep disabled (second commit never removed the superseded …), green with it — and by the integration suite; - the whole sequence — Promote, the pointer flip, the sweep — runs under one writer per profile
(
store.LockProfile, a Postgres advisory lock). The independent Codex read of the first version found the race this closes: committed keys are content-addressed and therefore re-creatable, so “commit X→Y, park the sweep of X, commit Y→X, resume” deleted the object the pointer had just returned to. A test builds exactly that interleaving with a parked fake removal; it fails deterministically without the lock. The second read then found two more, both fixed the same day: the lock is taken before the lease renewal and the version comparison (two pushes could pass the same check and let the stale one commit last), and a waiter holds no pooled connection while it waits (pg_try_advisory_lockpolled every 25 ms — the blocking form let one client’s N pushes of one profile exhaust the pool and hang everything; a test on a pool of two proved the hang); - staged uploads live under
staging/(they were…/<hash>.staging, a suffix no lifecycle rule can select), and the service installs one rule at boot: expirestaging/after a day. Read back from MinIO:expire-staged-uploads · Enabled · staging/ · 1 day. A store that refuses the rule gets a WARN, not a refusal to start.
Inventory after two pushes of one profile over the tailnet: 1 object, staging/ empty. The three
objects S1’s runs left (two versions of one profile, one orphaned .staging) are still there — the fix does
not sweep the past, and an operator who wants them gone removes them once by hand (mc rm).
11.4 What the server sees — unchanged
Read from the database after the run, as S1 and S2 did: the new account row (email, argon2id hash of the
verifier, 16-byte salt, totp_enabled=false), one device, one profiles_meta row now pointing at the only
object in the bucket for that profile, zero audit rows. Nothing this session added is readable to the server
that was not before; the Tailscale layer terminates TLS on this host, inside the owner’s tailnet, and forwards
to loopback.
11.5 If a device outside the tailnet must reach it — [INTENT]
# API only, public: one of Funnel's three ports is free here
tailscale funnel --bg --https=10000 http://127.0.0.1:8080
# API + object store, public, under your domain (needs a Cloudflare account + a domain on it):
cloudflared tunnel login
cloudflared tunnel create vflin
cloudflared tunnel route dns vflin api.example.com
cloudflared tunnel route dns vflin s3.example.com
# config.yml: ingress api.example.com → http://127.0.0.1:8080, s3.example.com → http://127.0.0.1:19000
cloudflared service install # runs as a Windows service, so it does NOT wait for a sign-in
then VFLIN_S3_PUBLIC_ENDPOINT=s3.example.com, VFLIN_S3_PUBLIC_USE_SSL=1, restart cloud. Not run: no
account, no domain, and no word from the owner that anything should face the public internet.