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?"

Little Moments Design Doc

Objective

Create a web app that allows parents to share photos and videos of their children with close friends and family members.

Background

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.

Goals

Non-goals

User roles

User interface

Little Moments exposes a small number of interfaces so that relatives do not have to learn a complex product.

Timeline feed

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.

Invite and sign-in flow

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.

Notification settings

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.

Scenarios

Scenario: Parent imports a TinyBeans export

  1. A parent signs in as the server owner and opens the import page.
  2. The parent uploads a tinybeans-export archive from their laptop.
  3. Little Moments validates the archive structure, imports journal metadata, users, media, comments, and notification preferences, and reports any records it skipped.
  4. The parent reviews the imported timeline before sending invitations to relatives.

Scenario: Relative checks the family timeline

  1. A grandparent receives an invite email and clicks the sign-in link.
  2. Little Moments asks them to set a password and optionally choose daily summary emails.
  3. After signing in, they land on the newest post in the timeline.
  4. They open a photo album, leave a comment, and return later from the daily summary email.

Scenario: Parent posts a new video

  1. A parent signs in from a phone browser and taps New post.
  2. They select a video, write a caption, and tag the relevant child.
  3. The browser uploads the original file while the server generates smaller delivery variants.
  4. The new post appears in the timeline immediately for signed-in users and in the next digest email for users on daily summaries.

Missing features

Users

Notifications

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.

Service level objectives (SLOs)

Architecture

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.

Browser client

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.

Application server

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.

Relational database

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.

Object storage

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.

Background job worker

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.

Email provider

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

Privacy is the primary product differentiator, so the design should minimize data collection by default.

Data retention

Little Moments should keep retention rules simple and understandable.

Security

The security model should assume a small trusted family group but still defend against common web risks.

Licensing

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.

Implementation timeline

The timeline aims to preserve the explicit goal that v1 fits within 40 development hours.

Alternatives considered

Keep using TinyBeans

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.

Build a multi-tenant hosted SaaS

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.

Reuse a general-purpose photo manager

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.

Build native mobile apps first

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.

Appendix: tinybeans-export format

File structure

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.json

The 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.json

Media 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
    }
  ]
}