Saved data
Deprecated, existing sites only. New sites use storage resources (KV, SQLite, files) — see storage-migration.md and storage-and-backups.md.
This page describes the existing state, collection and declared-kind API, which remains supported. New Simple Host sites may instead use owner-declared KV, SQLite and files resources with independent whole-resource read/write policies. See the storage migration plan before moving an existing site’s data; its private Submissions, Personal records, atomic operations and undo do not automatically map to a shared resource.
Every site gets a small backend in the same upload: one shared JSON document (page data) with atomic operations, and lists visitors add to (RSVPs, votes, sign-ups). A list can be private: only signed-in visitors add; the owner reads all, and each visitor reads their own.
Kinds. Each data name is one kind, and the kind decides who reads and who changes what:
Page info (the owner writes, everyone reads), Submissions (visitors send entries; private
to the owner by default; each visitor sees, changes and withdraws their own; an optional email
digest to the owner), Personal (one private record per signed-in visitor) and Shared
board (a list a group edits item by item). A name nobody declared is Shared, as before the
kinds; SAVED_DATA_DEFAULT_KIND=declare_first makes new sites take no saves under an undeclared
name. The owner can limit who may save on a site to listed emails and @domains, and block
people. The SAVED_DATA_* settings below set each kind’s sizes and counts.
Undo. Every change to page data, and every deleted or edited list item, is kept for
SAVED_DATA_UNDO_DAYS and can be restored by the site’s owner. History past its size cap is
thinned oldest first, always keeping each item’s first change of every day. Unlike the dates in
Cleanup and retention, shortening the undo window applies to what is already kept at the next
sweep.
Limits. A site’s live saved data may not grow past SAVED_DATA_SITE_MAX_MB; writes that do
not grow it always go through. Reads and list additions are rate limited per address.
The watch. The admin page counts, per site and day, the visitor writes a later step may tighten (still counted; not enforced yet), so an operator can see who would be affected first.
Who may write. WRITE_AUTH_MODE=on makes every page save need a signed-in visitor or the
owner’s key.
| Setting | Default | Allowed | What it does |
|---|---|---|---|
NOTIFY_MAX_PER_SITE |
10 |
1–1000 subscriptions | Notification subscriptions allowed per site. |
NOTIFY_PER_MINUTE |
60 |
1–100000 events | Visitor events accepted per site per minute. |
NOTIFY_PER_DAY |
5000 |
1–10000000 events | Visitor events accepted per site per day. |
NOTIFY_HISTORY_DAYS |
7 |
1–365 days | Days of notification events and delivery history retained. |
NOTIFY_OWNER_CONCURRENCY |
5 |
1–200 deliveries | Concurrent notification deliveries allowed per owner across all workers. |
NOTIFY_PER_URL_PER_MINUTE |
120 |
1–100000 attempts | Notification attempts per exact URL per minute across all sites and subscriptions. |
NOTIFY_WORKERS |
20 |
1–200 workers | Concurrent notification delivery workers. |
NOTIFY_ATTEMPTS |
5 |
1–20 attempts | Maximum delivery attempts per event and subscription. |
NOTIFY_FIELD_MAX_BYTES |
2048 |
1–65536 bytes | Maximum bytes in a notification string field before truncation. |
NOTIFY_BODY_MAX_BYTES |
16384 |
4096–262144 bytes | Maximum event body bytes before fields are omitted. |
NOTIFY_SUSPEND_ATTEMPTS |
100 |
1–100000 attempts | Attempts needed before the rolling failure percentage can suspend a subscription. |
NOTIFY_SUSPEND_FAILURE_PERCENT |
95 |
1–100 percent | Failure percentage above which a subscription is suspended. |
NOTIFY_TIMEOUT_MS |
5000 |
1–30000 milliseconds | Total timeout for a notification attempt, in milliseconds. |
NOTIFY_DEFAULT_TTL_HOURS |
168 |
1–8760 hours | Default lifetime of an MCP event subscription, in hours. |
NOTIFY_SUSPEND_WINDOW_HOURS |
1 |
1–168 hours | Rolling window for the suspension failure percentage, in hours. |
NOTIFY_SUSPEND_DAYS |
3 |
1–365 days | Days of continuous failures before a subscription is suspended. |
NOTIFY_PUSH_PER_SITE_PER_DAY |
2000 |
1–10000000 pushes | SIM-360: web-push sends allowed per site per day, on top of the general NOTIFY_PER_DAY event cap. |
NOTIFY_PUSH_PER_PERSON_PER_DAY |
200 |
1–100000 pushes | SIM-360: web-push sends allowed per recipient (owner or one visitor) per day across their own devices. |
RATE_LIMIT_SPAM_CHECKED_IP |
10,30s |
stricter freely; loosest 40,7.5s |
Posts on comments-preset resources (anyone posts after the Turnstile spam check), per address across every site. Security-sensitive. |
RATE_LIMIT_SPAM_CHECKED_SITE |
60,10s |
stricter freely; loosest 240,2.5s |
Posts on comments-preset resources, per site from everyone together. Security-sensitive. |
SPAM_CHECKED_POST_MAX_BYTES |
4000 |
100–65536 bytes | Largest post someone not signed in may add to a comments-preset resource (the JSON of its values). |
SPAM_CHECKED_POST_MAX_LINKS |
2 |
0–50 links | Links (http://, https://, www.) one such post may contain. |
RATE_LIMIT_STATE |
60,1s |
any (warns past 10× looser) | Saved-data and list writes per client. |
SAVED_DATA_UNDO_DAYS |
30 |
1–365 days | How long every change to saved data, and every deleted list item, can be restored. |
SAVED_DATA_HISTORY_MAX_MB |
20 |
1–10240 MB | A site’s saved-data history above this is thinned, oldest first. |
SAVED_DATA_SITE_MAX_MB |
50 |
1–10240 MB | Largest a site’s live saved data may grow. |
SAVED_DATA_SNAPSHOT_EVERY |
50 |
1–10000 changes | History keeps a full copy of a site’s data at least this often. |
SAVED_DATA_SWEEP_MINUTES |
15 |
1–1440 minutes | How often expired history and deleted items are removed. |
SAVED_DATA_WATCH_DAYS |
7 |
1–365 days | The window the admin page’s saved-data watch reports. |
SAVED_DATA_WATCH_INC_MAX |
10 |
1–1000000000 count | A visitor increment larger than this counts as large in the watch. |
SAVED_DATA_WATCH_ITEM_KB |
16 |
1–64 KB | A list item larger than this counts as large in the watch. |
SAVED_DATA_WATCH_KEEP_DAYS |
90 |
1–3650 days | Watch counts older than this are removed. |
SAVED_DATA_IDEMPOTENCY_HOURS |
24 |
1–720 hours | How long a retried write gets its first answer back instead of applying twice. |
SAVED_DATA_IDEMPOTENCY_MAX_PER_SITE |
10000 |
100–1000000 keys | Retry keys remembered per site; the oldest past this are dropped. |
SAVED_DATA_READ_PER_SEC |
30 |
1–10000 reads | Saved-data reads per second per site and address. |
SAVED_DATA_READ_BURST |
60 |
1–100000 reads | Saved-data reads allowed at once above that rate. |
SAVED_DATA_APPEND_PER_MIN |
30 |
1–10000 items | List items one address may add per minute without the owner’s key. |
SAVED_DATA_APPEND_BURST |
30 |
1–100000 items | List items allowed at once above that rate. |
SAVED_DATA_CONTENT_MAX_KB |
1024 |
1–10240 KB | Largest one Page info document may be. |
SAVED_DATA_CONTENT_NAMES_MAX |
20 |
1–1000 names | Page info names one site may declare. |
SAVED_DATA_ENTRY_MAX_KB |
16 |
1–64 KB | Largest one new Submissions entry may be. |
SAVED_DATA_ENTRIES_MAX |
10000 |
1–1000000 entries | Live entries one Submissions name may hold. |
SAVED_DATA_WITHDRAW_UNDO_MINUTES |
10 |
1–1440 minutes | How long a visitor can bring back an entry they withdrew. |
SAVED_DATA_NOTIFY_EACH_MINUTES |
10 |
1–1440 minutes | “Email me: each” sends at most one email per name this often, listing what arrived. |
SAVED_DATA_NOTIFY_DAILY_HOURS |
24 |
1–720 hours | “Email me: daily” sends at most one digest per name this often. |
SAVED_DATA_SAVERS_MAX |
500 |
1–100000 entries | Emails and domains in one site’s who-may-save and block lists together. |
SAVED_DATA_ENTRIES_NAMES_MAX |
50 |
1–1000 names | Submissions names one site may declare. |
SAVED_DATA_PERSONAL_MAX_KB |
64 |
1–1024 KB | Largest one person’s record in one Personal name may be. |
SAVED_DATA_PERSONAL_NAMES_MAX |
20 |
1–1000 names | Personal names one site may declare. |
SAVED_DATA_BOARD_ITEM_MAX_KB |
16 |
1–64 KB | Largest one Shared board item may be. |
SAVED_DATA_BOARD_MAX |
2000 |
1–1000000 items | Live items one Shared board may hold. |
SAVED_DATA_BOARD_NAMES_MAX |
20 |
1–1000 names | Shared board names one site may declare. |
SAVED_DATA_PERSONAL_PEOPLE_MAX |
1000 |
1–1000000 people | People who may hold a record in one Personal name. |
SAVED_DATA_BOARD_WRITES_PER_MIN |
30 |
1–10000 writes | Shared board adds, changes and deletes one signed-in person may make per minute, on top of the per-address rate. |
SAVED_DATA_DEFAULT_KIND |
shared |
shared / declare_first |
What a name no one declared is on a site made after the kinds. shared: anyone reads it and signed-in visitors save to it. declare_first: it takes no saves until the owner declares it. Older sites are shared either way. Security-sensitive. |
TURNSTILE_SITE_KEY |
none | text | The Cloudflare Turnstile site key for the spam check on the comments preset (anyone posts without signing in once the check passes). With no site key and secret, the preset is not offered. The widget’s hostnames must include this server’s own address (PUBLIC_BASE_URL), where the check runs in a small frame. |
TURNSTILE_SECRET |
none | secret | The Turnstile secret key that goes with TURNSTILE_SITE_KEY. Prefer TURNSTILE_SECRET_FILE. Security-sensitive. |
TURNSTILE_SECRET_FILE |
none | text | A file holding the Turnstile secret key (read once at start, used when TURNSTILE_SECRET is empty), for example a systemd credential: LoadCredential=turnstile-secret:/path and TURNSTILE_SECRET_FILE=%d/turnstile-secret. Security-sensitive. |
PHOTODNA_API_KEY |
none | secret | Subscription key for Microsoft PhotoDNA Cloud Service, which checks uploaded images against known abuse hash sets. Empty: image matching is off and nothing is queued. Prefer PHOTODNA_API_KEY_FILE. Security-sensitive. |
PHOTODNA_API_KEY_FILE |
none | text | A file holding the PhotoDNA key, read once at start when PHOTODNA_API_KEY is empty, for example a systemd credential: LoadCredential=photodna-key:/etc/simple-host-secrets/photodna-key and Environment=PHOTODNA_API_KEY_FILE=%d/photodna-key. Unreadable: matching is off, with a warning in the log. Security-sensitive. |
PHOTODNA_MATCH_URL |
https://api.microsoftmoderator.com/photodna/v1.0/Match |
text | The PhotoDNA Match operation. |
PHOTODNA_REPORT_URL |
https://api.microsoftmoderator.com/photodna/v1.0/Report |
text | The PhotoDNA Report operation, which files a CyberTipline report with NCMEC through PhotoDNA. |
PHOTODNA_AUTO_REPORT |
off |
off / on |
on: every match is reported through the Report operation as soon as it is recorded. off: the admin Moderation tab offers a Report button per match. |
IMAGE_SCAN_REPORT_ORG |
none | text | Organisation name on reports. Reports are off until this, IMAGE_SCAN_REPORT_NAME, and the report email are all set. |
IMAGE_SCAN_REPORT_NAME |
none | text | Reporter’s name on reports. Reports are off without it. |
IMAGE_SCAN_REPORT_EMAIL |
the support contact |
text | Reporter’s email on reports. |
IMAGE_SCAN_ALERT_EMAIL |
the support contact |
text | Where each match alert is emailed. |
IMAGE_SCAN_EVIDENCE_DIR |
<DATA_DIR>/.evidence |
text | Folder for the quarantined copy and the record of each match (mode 0700, under no served path). Must be writable by the service (ReadWritePaths on a hardened unit). |
WRITE_AUTH_MODE |
log |
off / log / on |
on: page saves need a signed-in visitor or the owner’s key. log: allowed, and logged. Security-sensitive. |
LEGACY_STORAGE_CUTOFF |
off |
text | YYYY-MM-DD (00:00 UTC that day): sites created on or after this date get 410 legacy_storage_closed from the old saved-state, collections and declared-data routes. Empty or off disables the gate (the default, so self-hosted boxes are unchanged). An unparsable value logs a warning at boot and disables the gate. simple-host.app: 2026-10-12. |
Recipes
Shorter retention of saved-data history.
SAVED_DATA_UNDO_DAYS=7
SAVED_DATA_HISTORY_MAX_MB=5
Smaller per-site data on a small disk.
SAVED_DATA_SITE_MAX_MB=10
Turn on the comments preset (anyone posts after a spam check). The preset needs a
Cloudflare Turnstile widget, which is free. Without both keys the server refuses the preset
(400 spam_check_unavailable), the dashboard and the connector leave it out, and
get_page_recipe sends guestbooks and comments to wall, where people sign in to post.
- In the Cloudflare dashboard, open Turnstile, choose Add widget, name it, and add this
server’s own address as the hostname: the host of
PUBLIC_BASE_URL(on a small box, the dashboard address, not the sites address). Widget mode Managed is fine. - Copy the site key and the secret key into the server’s environment. On a small box
installed with
install.sh, addTURNSTILE_SITE_KEY=...andTURNSTILE_SECRET=...to/opt/simple-host/.env(re-runninginstall.shkeeps both), then restart:
cd /opt/simple-host && sudo docker compose up -d
- The boot log says
spam check: Turnstile on. Your sites, Manage, Storage now offers Comments. Under systemd, preferTURNSTILE_SECRET_FILEwith a credential over the plain secret.
Spam-check credentials
Without the Turnstile site key and secret, the comments preset is refused with
400 spam_check_unavailable. Its frame is /v1/turnstile/frame on
PUBLIC_BASE_URL, whatever address the site uses. An unreadable
TURNSTILE_SECRET_FILE turns the check off. A systemd credential can supply it:
LoadCredential=turnstile-secret:/etc/simple-host-secrets/turnstile-secret and
Environment=TURNSTILE_SECRET_FILE=%d/turnstile-secret.
Legacy saved-data behavior
SAVED_DATA_UNDO_DAYS applies to data already kept: shortening it removes older
history and Recently deleted items at the next sweep, unlike the stored promises
for site deletion and domain release.
The site owner restores history and deleted items. History thinning keeps each
item’s first change of every day; it runs when a write crosses the cap (at most
once a second per site) and in the sweep. The live-data cap excludes history and
Recently deleted. Writes that grow it beyond the cap return 507 site_full;
writes that do not grow it still succeed. Changes made with ops store only the
change, with a full snapshot on the first change each day as well as at
SAVED_DATA_SNAPSHOT_EVERY.
GET /v1/admin/data-watch reports the watch window. SAVED_DATA_WATCH_KEEP_DAYS
must not be shorter than SAVED_DATA_WATCH_DAYS. Retries use Idempotency-Key.
Owner-key and connector reads are limited per account rather than per address.
Page info (content), Personal (mine) and Shared board (board) oversized
items return 413 item_too_large. The corresponding name caps, including
Submissions (entries), return 409 too_many_names. Submissions and Shared
board live-item caps return 409 list_full. Older Submissions entries up to
64 KB are kept. The Personal people cap counts Recently deleted records;
a new person’s first save beyond it returns 409 people_full, while existing
people keep saving. The per-person board write cap returns 429 and adds to
SAVED_DATA_APPEND_PER_MIN, so one account on many addresses goes no faster.
Undeclared names in declare_first mode return 409 declare_first until the
owner declares Page info or Submissions. Sites predating
sd2-saved-data-kinds.sql stay Shared; every later site follows the current
setting, so switching it changes all those sites. Shared keeps older skills and
uploaded pages working. The “Email me: each” interval also sets how often due
emails are checked.
Notification delivery behavior
The subscription cap counts agents and the dashboard together. Visitor events
over the per-minute cap are not sent. Per-URL delivery limiting uses an in-memory
token bucket with a one-attempt burst across all sites and subscriptions.
Attempts are spread over about 15 minutes; 410 and 413 responses are never retried.
Truncated string fields are listed under truncated. Oversized bodies omit
fields, then cut visitor-controlled paths and keys to 256 bytes if needed.
NOTIFY_DEFAULT_TTL_HOURS is also the maximum granted without an OAuth grant.
Image matching
See Image matching for PhotoDNA. These settings are
forwarded by server (systemd) installs; Docker Compose does not pass them through
yet. Without all three report identity fields, matches are recorded with report
status off; the admin Report button appears once they are set. Evidence paths
resolve symlinks, must be outside served paths and writable by the service
(ReadWritePaths on a hardened unit). Exclude evidence from your own backups.