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.
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.
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.
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.
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.
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).
Scenario: Parent uploads a photo
Scenario: Grandparent reacts to a photo
Scenario: Admin migrates from TinyBeans
tinybeans-export to download all his TinyBeans
data.
tinybeans-import --data-dir ./export against his
Little Moments server.
Scenario: Family member adjusts notifications
The following features are intentionally omitted from v1 to keep the implementation within the 40 dev hour budget:
repliesCount: 0 for all comments in practice, so this is
rarely used.
Assumptions about the user base:
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.
Since this is a self-hosted app for a small family audience, the SLOs are modest:
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 stores users, entries (metadata, captions, dates), comments, and reactions. SQLite is the right choice because:
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.
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.
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.
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:
.env file with their domain name and SMTP
credentials
docker compose updatabase/sql package.
html/template package,
which applies contextual auto-escaping by default.
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.
The v1 implementation targets 40 dev hours across four milestones:
tinybeans-import CLI that reads
tinybeans-export output and populates the database and
media directory.
docker-compose.yml with Caddy and the Little
Moments server.
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
}
]
}