A personal wiki inspired by TiddlyWiki, running serverless on AWS. There is no server to patch and nothing runs while nobody uses it, so a small wiki costs a few cents a month.
Notes are tiddlers: a title, text and tags. Open tiddlers form a story river; a link opens the next one right below the current one. Everything is stored in DynamoDB and S3 behind Cognito sign-in.
https://d1grd9lz4vxssg.cloudfront.net
Sign in with admin@example.com / admin123 (the fields are filled in for you).
The demo is public and wiped every hour: all tiddlers, drafts, uploaded files and users go away, and the admin password is put back. Two-factor authentication works in the demo too; the wipe turns it off for the admin, so nobody can lock the shared account for longer than an hour. A few example tiddlers — a video, a photo, a table, task lists and lorem ipsum — are written again after each wipe, so they are always there. Invitations do not send email in the demo; the temporary password is shown to the administrator instead.
- TiddlyWiki-style wikitext (
''bold'',//italic//,[[links]],{{transclusion}}, tables, lists,<<macros>>), Markdown or plain text per tiddler. - Tags, backlinks, missing links, full-text search, recent changes.
- Full history of every save, rename and delete, with restore into the editor.
- Autosaved drafts and open tiddlers, per user, restored on any device.
- Task lists (
* [ ] task) with live checkboxes and a<<todo>>summary. - Photo upload with a desktop-sized WebP copy and the original on click; video player for mp4/webm/mov/ogv.
- Settings → Version: the version of the site and the releases of the demo repository that came after it, with their notes.
- Interface in English, Russian, French and Italian, following the system language.
- Optional two-factor authentication with an authenticator app (TOTP), turned on or off in the settings by each user. Changing the password asks for the new one twice.
- Settings for administrators: invite, disable and delete users, see who has two-factor authentication and reset it for a lost phone; remove files no tiddler refers to.
Text is never parsed as HTML: the page builds DOM nodes itself, and uploaded files are served with a sandboxing Content-Security-Policy.
At sign-in the page gets the list of tiddlers without their text: title, tags, dates, authors, etag, size and the titles each tiddler links to. The text of a tiddler is loaded when it is opened; open tiddlers, {{transclusions}} and texts needed for an edit come in batches of up to 100. The list comes from a DynamoDB index that holds everything but the text.
What used to need every text works without it:
- backlinks and the Missing tab use the links the server works out on every save (
links; tiddlers saved before get them on the first listing); - search matches titles, tags and the texts already loaded at once, and the server searches every text 300 ms after typing stops;
<<todo>>asks the server for the open tasks; a tick from the summary loads that tiddler's text and saves it.
The server and the page use the same rules for links and tasks (extract_links and scan_tasks in lambda_app.py, links and tasks in wikitext.js).
flowchart LR
B[Browser] -->|HTTPS| CF[CloudFront]
B -->|sign in| COG[Cognito user pool]
CF -->|/| WEB[(S3: page)]
CF -->|/files/* signed cookie| FILES[(S3: attachments)]
CF -->|/api/*| API[API Gateway HTTP API<br/>JWT authorizer]
API --> L[Lambda: API]
L --> DDB[(DynamoDB)]
L --> FILES
L --> COG
EB[EventBridge<br/>rate 1 hour] --> R[Lambda: reset]
R --> DDB
R --> FILES
R --> COG
| Part | Service |
|---|---|
| Page | S3 + CloudFront (default *.cloudfront.net domain and certificate) |
| Sign-in | Cognito user pool, no self sign-up |
| API | API Gateway HTTP API with a Cognito JWT authorizer, Lambda (Python, arm64) |
| Tiddlers, history, drafts | DynamoDB on demand, one table |
| Attachments | Private S3 bucket read through CloudFront with a one-hour signed cookie |
| Demo reset | Lambda reset.py, EventBridge rule rate(1 hour) |
Nothing runs between requests, and most services fall within the AWS always-free allowances (Lambda 1M requests and 400,000 GB-s, CloudFront 1 TB and 10M requests, Cognito 10,000 monthly active users, DynamoDB 25 GB storage, CloudWatch Logs 5 GB). What remains is paid per request.
Estimate for light use — a handful of people, 20,000 API calls, 2,000 saves, 1 GB of attachments and 5 GB of traffic a month — at eu-central-1 list prices:
| Service | Usage | Price | Per month |
|---|---|---|---|
| Lambda (API + 720 hourly resets) | ~21,000 invocations, 256 MB | free allowance | $0.00 |
| API Gateway HTTP API | 20,000 requests | ~$1.20 per million | $0.02 |
| DynamoDB on demand | 2,000 writes, 50,000 reads, < 1 GB | ~$0.76 / $0.15 per million, storage free | $0.01 |
| DynamoDB point-in-time recovery | < 0.1 GB | ~$0.24 per GB | < $0.01 |
| S3 (page + attachments) | 1 GB, ~10,000 requests | ~$0.0245 per GB | $0.03 |
| CloudFront | 5 GB, 100,000 requests | free allowance | $0.00 |
| Cognito | < 10,000 active users | free allowance | $0.00 |
| CloudWatch Logs | < 1 GB | free allowance | $0.00 |
| Total | ≈ $0.07 |
With heavy use — 1 million API calls and 10 GB of attachments — it grows to roughly $1.20 (API Gateway) + $0.25 (S3) + $0.30 (DynamoDB) ≈ $2 a month. A WAF web ACL is left out on purpose: it alone would cost about $5–6 a month.
Prices are approximate and change over time; check the AWS pricing pages for your region.
-
An S3 bucket for Terraform state. The workflow uses the S3 backend with the native lockfile (Terraform 1.10+), so no DynamoDB lock table is needed; the state key is
wiki-serverless-aws/terraform.tfstate. -
The GitHub OIDC provider in the AWS account (
https://token.actions.githubusercontent.com, audiencests.amazonaws.com) and a role for this repository that trusts only jobs of the GitHub environmentdemo:{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:<owner>/wiki-serverless-aws:environment:demo" } } }Its permissions: everything named with the
wiki-serverlessprefix (thenamevariable: buckets, the table, functions, roles withiam:PassRole, log groups, SSM parameters, the EventBridge rule), API Gateway, Cognito and CloudFront (their ids are generated), log delivery for API access logs, and the state objects under the state key prefix. -
In the GitHub repository: the environment
demo, and the variablesAWS_ROLE_ARN(the role) andTF_STATE_BUCKET(the state bucket). There are no secrets.
.github/workflows/terraform.yml only calls the shared workflow of Tozapid/demo-ci-cd-skeleton; the same caller and the same jobs serve Hide-My-Email, inventory-serverless-aws and wiki-serverless-aws:
- every push and pull request, three jobs side by side:
terraform fmtandterraform validate; the Lambda tests; the page's syntax check and its tests, where there are any; - pull requests from this repository:
terraform planin the environmentdemo, summary in the run; - a push to
mainor a manual run: plan and apply in the environmentdemo. The job stops if the plan would destroy or replace a bucket, the table or the user pool; such a change has to be applied by hand. A manual run from another branch (Actions → terraform → Run workflow, pick the branch) deploys that branch the same way, over the same site: handy to try a change on the demo before merging. The next push tomainputs main back. Applies run one at a time across branches. - after
mainis deployed: the GitHub release of the newestCHANGELOG.mdentry, once (see Versions and releases). The caller grantscontents: writefor it.
main takes changes only through pull requests, merged by squash once the three checks pass (ci / Terraform format and validate, ci / Lambda tests, ci / Frontend tests); the branch is deleted after the merge.
The jobs get AWS credentials from GitHub's OIDC token (permissions: id-token: write, aws-actions/configure-aws-credentials with role-to-assume): no AWS key is stored anywhere, and a token lives for one job.
The site address is printed in the run summary and in terraform output site_url.
Every entry of CHANGELOG.md starts with its version, optionally followed by a date or a title: ## v1.4.0, ## v1.4.0 — 2026-10-12 or ## v1.4.0 — versions. Inside an entry the changes go under ### Added, ### Changed, ### Fixed and ### Security, newest entry first. A pull request that changes the site adds an entry with a higher version: minor for a feature, patch for a fix. The check ci / Terraform format and validate refuses a newest entry without one.
- The site takes its version from the newest entry when Terraform deploys it (
local.versioninlambda.tf). - After
mainis deployed, the jobci / Releasemakes the tag and the GitHub release of the newest entry, with its text as the notes, unless that version is out already. A pull request without a new entry releases nothing. - Settings → Version shows the version and the releases of
releases_repository(this repository by default) that came after it. The API (GET /api/version) reads them from the Atom feed of the GitHub releases (https://github.com/<repository>/releases.atom) and keeps them an hour. It does not use the REST API, whose hourly limit without a token is shared by every function behind Lambda's addresses. A copy deployed from a fork can compare itself with this repository by naming it inreleases_repository; an empty value turns the check off.
terraform init \
-backend-config="bucket=<state bucket>" \
-backend-config="key=wiki-serverless-aws/terraform.tfstate" \
-backend-config="region=eu-central-1"
terraform applyThe defaults set up the public demo. For a private wiki, change them in a terraform.tfvars:
| Variable | Demo | Private wiki |
|---|---|---|
demo |
true — the sign-in screen shows and fills in the admin sign-in |
false — nothing is shown |
admin_email, admin_password |
admin@example.com, admin123 |
your own |
reset_schedule |
rate(1 hour) |
"" — no reset and no example tiddlers |
invite_emails |
false |
true — Cognito emails the invitation |
password_min_length |
8 |
12 or more |
file_max_bytes |
10 MB | up to what you are ready to store |
| Path | What is there |
|---|---|
*.tf |
Terraform |
web/ |
The page: app.js (interface), wikitext.js (markup), i18n.js (translations), styles.css, index.html |
lambda/lambda_app.py |
API, with tests in lambda_app_test.py |
lambda/reset.py |
Hourly demo reset, with tests in reset_test.py |
seed/ |
Example tiddlers of the demo (tiddlers.json) and their files, uploaded to files/seed/ |
tests/web/ |
Frontend tests |
cloudfront/api_host.js |
CloudFront function that passes the site address to the API for the file cookie |
.github/workflows/ |
CI and deployment |
cd lambda && python -m unittest -v lambda_app_test reset_test
cd tests/web && npm ci && npm testLambda (lambda/*_test.py, Python unittest). The handlers run against in-memory stand-ins for DynamoDB, S3 and Cognito, with requests built the way API Gateway sends them:
- tiddlers: create, edit with a stale
etag, rename, delete, history; the list without text, texts in batches, links worked out for old tiddlers, server search and tasks; - open tiddlers and drafts per user, draft limits, error messages in the page language, titles that would break links;
- administration: only the
adminsgroup gets in, invite, disable and delete users, no locking yourself out, cleanup of only old unused files; - CloudFront cookie signing, checked byte for byte against
openssl; - the demo reset: wipes the table, every file version and other users, brings the admin back and writes the example tiddlers again, keeping their files; every file and link in the examples exists.
Frontend (tests/web/, node --test):
| File | What it checks |
|---|---|
wikitext.test.js |
The markup renderer in jsdom: wikitext and Markdown, links, lists, tables, media, transclusion and its depth limit, macros, task lists. Text such as <script>, onerror= or javascript: links never turns into anything that runs. |
i18n.test.js |
Every interface string has English, French and Italian text with the same {placeholders}; help in each language; the system language is picked unless one was chosen; plural forms. |
parity.test.js |
The page and the Lambda find the same links and tasks in about 500 documents, since backlinks and <<todo>> come from the server. Needs python3. |
ui.test.js |
The page in headless Chrome against an in-memory API: sign-in, only the texts on screen are loaded, create and edit, a conflicting save keeps the editor, ticking a task, <<todo>>, search, open tiddlers after a reload, switching language. Chrome is found on the usual paths or taken from CHROME_PATH; without it these tests are skipped locally and fail in CI. |
Run on the demo on 27 September 2026: 3,000 tiddlers created through the API, then the interface measured in headless Chrome on a MacBook over a home connection.
Data. 3,000 tiddlers, 11.7 MB of text: most 0.5–3 KB, 9 % 5–20 KB, 1 % 50–100 KB; 40 tags, 10 % Markdown, headings, lists, tables, code, 11,466 open tasks, links between tiddlers and to 50 missing pages, 156 tiddlers with {{transclusion}}.
| What | Result |
|---|---|
| Creating 3,000 tiddlers, 8 parallel writers | 150 s, 0 failures. 20 saves/s: the API Gateway stage limit (throttling_rate_limit = 20); 781 requests got 429 and succeeded on retry |
| Save latency | p50 175 ms, p95 229 ms, max 3.6 s (a retry wait) |
| Data at sign-in | 1.46 MB of tiddler list in 2 pages, plus 13 KB of text for the open tiddlers |
| Sign-in until the page shows | 4.3–4.6 s (three runs) |
| Opening a tiddler not loaded yet | 160–200 ms, one request for its text |
| Search | titles and tags at once; all text 2.6 s on the server |
<<todo>> over the whole wiki (11,466 tasks) |
3.9 s on the server |
| All tab (3,000 rows), Missing tab (50) | from the stored links, no text needed |
| Worst transclusion tree | 6 renders: the depth limit keeps {{…}} from exploding |
| JavaScript heap after the test | 8 MB |
Conclusion. Sign-in grows with the number of tiddlers, not with their text: the list is about 0.5 KB a tiddler, most of it the titles each one links to. Full-text search and <<todo>> read the whole table on the server, so they take a few seconds on a wiki of this size.
- Everyone signed in sees and edits every tiddler; there are no per-page permissions.
- The list of all tiddlers (without text) is loaded at sign-in, and full-text search and
<<todo>>scan the whole table. That is fine for tens of thousands of tiddlers, not for millions. - The Cognito invitation email has one language for the whole pool.