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

There are three user roles:

Role Description Capabilities
Admin The person who hosts the server. There is exactly one admin per server. Upload photos/videos, write captions, delete any entry, manage all users, configure server settings.
Contributor A trusted family member (e.g., the other parent). Upload photos/videos, write captions, add comments and reactions, delete their own entries.
Viewer Extended family and friends who follow along. View the timeline, add comments, add reactions.

The admin creates accounts for all other users. There is no self-registration. During the TinyBeans import, the migration tool maps TinyBeans coOwner users to the Contributor role and all other followers to the Viewer role.

User interface

The app has a mobile-first responsive design with three main views: Timeline, Upload, and Settings. The interface is deliberately simple to be comfortable for less computer-literate family members.

Timeline view

The timeline is the default view. It shows entries in reverse chronological order, grouped by date. Each entry displays the photo or video, caption, uploader name, reaction count, and comment count. Tapping an entry opens a detail view with full-size media and the comment thread.

Upload view

The upload view is available to Admins and Contributors. It presents a simple form with a date picker (defaulting to today), a file picker for photos/videos, and an optional caption field.

Settings view

The settings view lets any user update their display name, email address, password, and notification preferences. Admins see an additional section for managing users (invite, change role, remove).

Scenarios

Scenario: Parent uploads a photo

  1. Homer opens Little Moments on his phone and taps “Upload.”
  2. Homer selects a photo from his camera roll and adds the caption “Baby’s first milkshake!”
  3. Homer taps “Upload.” The photo appears on the timeline immediately.
  4. That evening, the server sends a daily summary email to Grampa and Maude with a thumbnail of the new photo and a link to the timeline.

Scenario: Grandparent reacts to a photo

  1. Grampa receives the daily summary email and taps the link.
  2. The link opens the timeline in his browser. Grampa sees the new photo.
  3. Grampa taps the heart icon to react, then writes a comment: “He looks just like Homer at that age!”

Scenario: Admin migrates from TinyBeans

  1. Homer runs tinybeans-export to download all his TinyBeans data.
  2. Homer runs tinybeans-import --data-dir ./export against his Little Moments server.
  3. The import tool creates user accounts for each TinyBeans follower, imports all photos/videos with their original dates and captions, and preserves all comments and reactions.
  4. Homer sends each family member their new login credentials.

Scenario: Family member adjusts notifications

  1. Maude finds the daily emails too frequent and wants to check the app on her own schedule.
  2. Maude logs in, goes to Settings, and sets her notification preference to “None.”
  3. Maude no longer receives any emails. She visits the app directly when she wants to see new photos.

Missing features

The following features are intentionally omitted from v1 to keep the implementation within the 40 dev hour budget:

Users

Assumptions about the user base:

Notifications

Little Moments sends email notifications. There are no push notifications, no in-app notification badges, and no real-time updates. This is intentional: the app should feel calm and low-pressure.

Each user chooses one of two notification settings:

The server sends notifications via SMTP. The admin configures SMTP credentials (host, port, username, password) during server setup. The app does not bundle its own mail server.

Service level objectives (SLOs)

Since this is a self-hosted app for a small family audience, the SLOs are modest:

Architecture

Go HTTP server

The core of the application is a single Go binary that serves HTML pages and handles API requests. Go is a good fit because:

The server renders HTML on the server side using Go’s html/template package. There is no JavaScript framework. The frontend uses minimal vanilla JavaScript only where necessary (e.g., file upload progress, reaction toggles). Server-side rendering keeps the frontend simple and fast on older devices that family members might use.

SQLite

SQLite stores users, entries (metadata, captions, dates), comments, and reactions. SQLite is the right choice because:

Local filesystem

Photos and videos are stored on the local filesystem in a structured directory layout organized by date (/data/media/2025/10/20/<uuid>.jpg). This avoids the cost and complexity of object storage (like S3) for a single-family use case. Backups can be done with standard tools like rsync.

Reverse proxy

The Go server listens on a local port. A reverse proxy (Caddy or nginx) sits in front of it to handle TLS termination and serve as the public HTTPS endpoint. Caddy is the recommended default because it automatically provisions and renews Let’s Encrypt certificates with zero configuration.

tinybeans-import CLI

A separate CLI tool (tinybeans-import) reads the output of tinybeans-export and populates the Little Moments database and media directory. It runs once during migration and is not part of the running server.

Deployment

The server runs as a Docker container with two mounted volumes: one for the SQLite database and one for the media directory. The docker-compose.yml file includes the Little Moments server and Caddy as services. A new user can go from zero to a running server by:

  1. Cloning the repository
  2. Editing a .env file with their domain name and SMTP credentials
  3. Running docker compose up

Privacy

Data retention

Security

Licensing

The app is released under the MIT License. This license:

The MIT License is appropriate because the project has no commercial ambitions (hosting other people’s family photos is an explicit non-goal). The goal is maximum adoption among tech-savvy parents who want to self-host, and a permissive license removes friction.

Implementation timeline

The v1 implementation targets 40 dev hours across four milestones:

Alternatives considered

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