This is one of three versions of the same design document that I created as a writing experiment:
For more details, see "Which Design Doc Did a Human Write?"
Create a web app that allows parents to share photos and videos of their children with close friends and family members.
I’m a paying customer of TinyBeans, a popular app for sharing family photos.
I subscribed to TinyBeans because they advertise it as privacy-friendly and ad-free, but after I signed up, I discovered that TinyBeans still injects ads into your photo albums and spams paying customers with ads.
I find it gross that a service for sharing baby photos injects ads into my family photos and collects ad-targeting information about my friends and family, especially when I pay for my usage.
I want to build an alternative photo sharing app that’s ad-free and easy for tech-savvy users to host for themselves privately.
Little Moments exposes a small number of interfaces so that relatives do not have to learn a complex product.
The default signed-in view is a reverse-chronological timeline of posts. Each card shows the photo or video, caption, child tags, posting date, and a compact comment thread. The feed should work well on a phone browser because many relatives will only interact from mobile Safari or Chrome.
Parents invite relatives by email. The email contains a single-use sign-in link that creates the initial session, prompts the relative to set a password, and lands them directly in the timeline. Returning users can sign in with email and password without needing a magic link each time.
Each user gets a narrow settings page with three choices: daily summary, comment-only emails on their own activity, or no email. The page also shows the local timezone used to deliver the digest so the behavior is predictable for non-technical relatives.
tinybeans-export archive from their
laptop.
New post.
Notifications are intentionally quiet. The system sends email only; it does not send push notifications, SMS, or in-app badges that encourage compulsive checking.
The app stores notification preferences per user and attempts to preserve equivalent TinyBeans settings during import. All email sending should degrade safely: if email delivery is unavailable, timeline access still works and the app surfaces an admin warning rather than silently dropping errors.
The architecture should stay boring and easy to self-host. A monolithic web application with a small number of external dependencies is the right tradeoff for a sub-40-hour v1.
The client is a server-rendered web UI with light JavaScript for uploads, progressive media loading, and comment interactions. Server rendering reduces frontend complexity, improves first-load performance for relatives on older devices, and keeps the codebase small.
The application server handles authentication, authorization, timeline rendering, comment workflows, import orchestration, and email scheduling. Keeping these concerns in one deployable service minimizes operational overhead and avoids inventing service boundaries before the app needs them.
PostgreSQL stores users, roles, posts, comments, notification preferences, import job state, and media metadata. Relational storage matches the data model well because the product has clear ownership and permission relationships, and it simplifies migrations from structured TinyBeans exports.
Original uploads and generated media variants live in S3-compatible object storage. Object storage is cheaper and operationally simpler than storing binary blobs in the database, and it keeps the path open for local disk-backed S3 implementations on low-cost hosts.
A worker process runs import jobs, media transcoding, thumbnail generation, digest email assembly, and cleanup tasks. Separating these jobs from request handling keeps the interactive app responsive without turning the system into a distributed platform.
The app uses a transactional email provider for invites, password resets, and daily summaries. This avoids running a mail server on a home-scale deployment and gives the operator better deliverability with less maintenance.
Privacy is the primary product differentiator, so the design should minimize data collection by default.
Little Moments should keep retention rules simple and understandable.
The security model should assume a small trusted family group but still defend against common web risks.
Little Moments should be released under the AGPL-3.0 license.
That choice matches the self-hosted goal and discourages a hosted third party from taking the code private while still allowing families to run and modify the software for themselves. The project should keep all first-party source code, deployment scripts, and documentation under the same license unless a dependency requires a compatible exception.
The timeline aims to preserve the explicit goal that v1 fits within 40 development hours.
This is the lowest-effort option, but it fails the privacy and product-direction goals. Continuing to pay for a product that still injects ads and tracks family activity does not solve the motivating problem.
A hosted service would reduce setup friction for users, but it creates major operational, legal, and privacy obligations. It also conflicts with the explicit non-goal of running a commercial service and would push v1 far beyond the 40-hour budget.
Tools such as generic gallery software can store media, but they do not naturally model invited family relationships, imported TinyBeans metadata, calm notification behavior, or a child-centered timeline. Adapting one would likely add as much complexity as building a purpose-fit v1.
Mobile apps could improve upload ergonomics, but they add substantial release and maintenance overhead. A responsive web app covers the core use cases with less implementation cost and better alignment with self-hosting.
tinybeans-export creates a file structure like the following:
.
├── 2024-08-18_650648406
│ ├── c77f0d7f-6d8f-429e-be50-9bf161d68d1d-o.jpg
│ └── metadata.json
├── 2025-12-27_714502829
│ ├── 01038d5e-5f16-46ed-980b-1a06bed27058thumbnail-o.jpg
│ ├── a9a574bf-94b7-4b5c-a19a-b5ab74bda55d.mp4
│ └── metadata.json
├── ...
├── followers.json
└── journal.json
Each folder is a piece of media containing the original size photo, original size video + thumbnail, and a metadata JSON file.
journal.json
The journal.json contains data like the following, where
Homer Simpson is the parent and Bart Simpson is the child:
{
"id": 9876123,
"timestamp": 1724014779666,
"title": "Bart Simpson",
"user": {
"id": 1112221,
"timestamp": 1724014779485,
"lastUpdatedTimestamp": 1769876901930,
"fullName": "Homer Simpson",
"firstName": "Homer",
"lastName": "Simpson",
"hasMemoriesAccess": true
},
"children": [
{
"id": 8882233,
"timestamp": 1724026487665,
"lastUpdatedTimestamp": 1724026487665,
"firstName": "Leo",
"lastName": "Simpson",
"fullName": "Bart Simpson",
"gender": "MALE",
"dob": "1987-04-01",
"avatars": {
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-s.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-m.png",
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-l.png"
},
"user": {
"id": 1112221,
"timestamp": 1724014779485,
"lastUpdatedTimestamp": 1769876901930,
"fullName": "Homer Simpson",
"firstName": "Homer",
"lastName": "Simpson",
"hasMemoriesAccess": true
}
}
]
}
followers.jsonThe followers.json file has data like the following:
[
{
"id": 5587986,
"URL": "https://tinybeans.com/api/1/journals/9876123/followers/5587986",
"timestamp": 1724014779668,
"journalId": 9876123,
"user": {
"id": 1112221,
"URL": "https://tinybeans.com/api/1/users/1112221",
"timestamp": 1724014779485,
"lastUpdatedTimestamp": 1769876901930,
"fullName": "Homer Simpson",
"firstName": "Homer",
"lastName": "Simpson",
"username": "parent@example.com",
"publicUsername": "",
"emailAddress": "parent@example.com",
"avatars": {
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-s.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-m.png",
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-l.png"
},
"timeZone": {
"name": "America/New_York",
"label": "(GMT-5:00) America/New_York",
"offset": 0
},
"timeZoneOffset": -18000000,
"hasMemoriesAccess": true,
"deleted": false,
"emailOptOut": false,
"emailMarketingOptOut": true,
"emailWeeklySummary": false,
"emailFrequencyOnNewComment": {
"name": "NONE",
"label": "Do not send"
},
"emailFrequencyOnNewEmotion": {
"name": "NONE",
"label": "Do not send"
}
},
"relationship": {
"name": "FATHER",
"label": "Father"
},
"viewEntries": true,
"addEntries": true,
"viewMilestones": true,
"editMilestones": true,
"coOwner": false,
"sortOrder": 0,
"sendFlashback": false,
"emailFrequencyOnNewEvent": {
"name": "DAILY",
"label": "Send once a day"
}
},
{
"id": 1234890,
"URL": "https://tinybeans.com/api/1/journals/9876123/followers/1234890",
"timestamp": 1724026924544,
"journalId": 9876123,
"user": {
"id": 2221112,
"URL": "https://tinybeans.com/api/1/users/2221112",
"timestamp": 1724026924424,
"lastUpdatedTimestamp": 1769626595834,
"fullName": "Marge Simpson",
"firstName": "Marge",
"lastName": "Simpson",
"username": "parent2@example.com",
"publicUsername": "",
"emailAddress": "parent2@example.com",
"avatars": {
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-s.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-m.png",
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-l.png"
},
"timeZone": {
"name": "America/Chicago",
"label": "(GMT-6:00) America/Chicago",
"offset": 0
},
"timeZoneOffset": -21600000,
"hasMemoriesAccess": true,
"deleted": false,
"emailOptOut": false,
"emailMarketingOptOut": true,
"emailWeeklySummary": true,
"emailFrequencyOnNewComment": {
"name": "IMMEDIATE",
"label": "Send immediately"
},
"emailFrequencyOnNewEmotion": {
"name": "IMMEDIATE",
"label": "Send immediately"
}
},
"relationship": {
"name": "MOTHER",
"label": "Mother"
},
"viewEntries": true,
"addEntries": true,
"viewMilestones": true,
"editMilestones": true,
"coOwner": true,
"sortOrder": 0,
"sendFlashback": true,
"emailFrequencyOnNewEvent": {
"name": "NONE",
"label": "Do not send"
}
},
{
"id": 3953212,
"URL": "https://tinybeans.com/api/1/journals/9876123/followers/3953212",
"timestamp": 1724167761726,
"journalId": 9876123,
"user": {
"id": 9992223,
"URL": "https://tinybeans.com/api/1/users/9992223",
"timestamp": 1724167761595,
"lastUpdatedTimestamp": 1768396343391,
"fullName": "Grampa Simpson",
"firstName": "Grampa",
"lastName": "Simpson",
"username": "grampa@example.com",
"publicUsername": "",
"emailAddress": "grampa@example.com",
"avatars": {
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-s.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-m.png",
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-l.png"
},
"timeZone": {
"name": "America/Detroit",
"label": "(GMT-5:00) America/Detroit",
"offset": 0
},
"timeZoneOffset": -18000000,
"hasMemoriesAccess": true,
"deleted": false,
"emailOptOut": false,
"emailMarketingOptOut": true,
"emailWeeklySummary": true,
"emailFrequencyOnNewComment": {
"name": "IMMEDIATE",
"label": "Send immediately"
},
"emailFrequencyOnNewEmotion": {
"name": "IMMEDIATE",
"label": "Send immediately"
}
},
"relationship": {
"name": "GRANDFATHER",
"label": "Grandfather"
},
"viewEntries": true,
"addEntries": false,
"viewMilestones": false,
"editMilestones": false,
"coOwner": false,
"sortOrder": 0,
"sendFlashback": true,
"emailFrequencyOnNewEvent": {
"name": "DAILY",
"label": "Send once a day"
}
},
{
"id": 5444455,
"URL": "https://tinybeans.com/api/1/journals/9876123/followers/5444455",
"timestamp": 1743948015529,
"journalId": 9876123,
"user": {
"id": 1112223,
"URL": "https://tinybeans.com/api/1/users/1112223",
"timestamp": 1743948015380,
"lastUpdatedTimestamp": 1769517178722,
"fullName": "Maude Flanders",
"firstName": "Maude",
"lastName": "Flanders",
"username": "Maude.Flanders@example.com",
"publicUsername": "",
"emailAddress": "Maude.Flanders@example.com",
"avatars": {
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-s.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-m.png",
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-l.png"
},
"timeZone": {
"name": "America/New_York",
"label": "(GMT-5:00) America/New_York",
"offset": 0
},
"timeZoneOffset": -18000000,
"hasMemoriesAccess": true,
"deleted": false,
"emailOptOut": false,
"emailMarketingOptOut": false,
"emailWeeklySummary": true,
"emailFrequencyOnNewComment": {
"name": "IMMEDIATE",
"label": "Send immediately"
},
"emailFrequencyOnNewEmotion": {
"name": "IMMEDIATE",
"label": "Send immediately"
}
},
"relationship": {
"name": "FRIEND",
"label": "Friend"
},
"viewEntries": true,
"addEntries": true,
"viewMilestones": true,
"editMilestones": false,
"coOwner": false,
"sortOrder": 0,
"sendFlashback": true,
"emailFrequencyOnNewEvent": {
"name": "DAILY",
"label": "Send once a day"
}
}
]
metadata.jsonMedia metadata files have a structure like the following:
{
"id": 777773331,
"journalId": 9876123,
"userId": 2221112,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331",
"timestamp": 1761779844168,
"lastUpdatedTimestamp": 1761829948040,
"year": 2025,
"month": 10,
"day": 20,
"caption": "Baby's first milkshake!",
"privateMode": false,
"uuid": "77722211-5666-4939-af12-aaaaaaabbbbb",
"type": "PHOTO",
"blobs": {
"o": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-o.jpg",
"o2": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-o2.jpg",
"t": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-t.jpg",
"s": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-s.jpg",
"s2": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-s2.jpg",
"m": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-m.jpg",
"l": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-l.jpg",
"p": "https://tinybeans.com/pv/e/777773331/44332211-6340-4b34-9770-cccccdddd111-p.jpg"
},
"sortOrder": 1,
"totalCommentsCount": 2,
"comments": [
{
"id": 111222111,
"entryId": 777773331,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/comments/111222111",
"timestamp": 1761825170399,
"lastUpdatedTimestamp": 1761825170399,
"user": {
"id": 4791052,
"timestamp": 1735939747014,
"lastUpdatedTimestamp": 1769606164702,
"fullName": "Waylon Smithers",
"firstName": "Waylon",
"lastName": "Smithers",
"hasMemoriesAccess": true
},
"details": "Everyone looks so happy!",
"repliesCount": 0
},
{
"id": 333333311,
"entryId": 777773331,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/comments/333333311",
"timestamp": 1761829948041,
"lastUpdatedTimestamp": 1761829948041,
"user": {
"id": 4795311,
"timestamp": 1737211058213,
"lastUpdatedTimestamp": 1767285698024,
"fullName": "Barney Gumble",
"firstName": "Barney",
"lastName": "Gumble",
"hasMemoriesAccess": true
},
"details": "What a great memory!",
"repliesCount": 0
}
],
"children": [
{
"URL": "https://tinybeans.com/api/1/children/8882233",
"avatars": {
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-l.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-m.png",
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-child-s.png"
},
"deleted": false,
"dob": "1987-04-01",
"firstName": "Bart",
"fullName": "Bart Simpson",
"gender": "MALE",
"id": 8882233,
"lastName": "Simpson",
"lastUpdatedTimestamp": 1724026487665,
"timestamp": 1724026487665,
"user": {
"avatars": {
"l": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-l.png",
"m": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-m.png",
"o": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-o.png",
"s": "https://tinybeans-public.s3-us-west-2.amazonaws.com/images/avatar-user-s.png"
},
"deleted": false,
"firstName": "Homer",
"fullName": "Homer Simpson",
"hasMemoriesAccess": true,
"id": 1112221,
"lastName": "Simpson",
"lastUpdatedTimestamp": 1769876901930,
"timestamp": 1724014779485
}
}
],
"emotions": [
{
"id": 751954309,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/emotions/751954309",
"type": {
"name": "LOVE",
"label": "Love"
},
"timestamp": 1761823202980,
"lastUpdatedTimestamp": 1761823202980,
"deleted": false,
"entryId": 777773331,
"userId": 4748247
},
{
"id": 751956133,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/emotions/751956133",
"type": {
"name": "LOVE",
"label": "Love"
},
"timestamp": 1761823886664,
"lastUpdatedTimestamp": 1761823886664,
"deleted": false,
"entryId": 777773331,
"userId": 4791061
},
{
"id": 751957695,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/emotions/751957695",
"type": {
"name": "LOVE",
"label": "Love"
},
"timestamp": 1761824428849,
"lastUpdatedTimestamp": 1761824428849,
"deleted": false,
"entryId": 777773331,
"userId": 4748246
},
{
"id": 751962461,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/emotions/751962461",
"type": {
"name": "LOVE",
"label": "Love"
},
"timestamp": 1761826117001,
"lastUpdatedTimestamp": 1761826117001,
"deleted": false,
"entryId": 777773331,
"userId": 4780152
},
{
"id": 751973108,
"URL": "https://tinybeans.com/api/1/journals/9876123/entries/777773331/emotions/751973108",
"type": {
"name": "LOVE",
"label": "Love"
},
"timestamp": 1761829939537,
"lastUpdatedTimestamp": 1761829939537,
"deleted": false,
"entryId": 777773331,
"userId": 4795311
}
]
}