Rent the boring parts: shipping Photo Pond in a day on free tiers


Most side projects don’t die in the code. They die in the setup: provisioning a database, wiring storage, configuring auth, building a deploy pipeline, and then paying for all of it every month before anyone has looked at the thing.

Photo Pond is my personal photo gallery. I built it to test a different approach: rent everything that isn’t the idea, spend money only on what makes it special, and keep the code mine so I can move it later.

The result:

  • The first working version (upload, gallery, auth, storage) landed in a single day of commits.
  • Infrastructure costs $0/month on free tiers.
  • The only real bill is the AI that catalogues the photos: about $0.01 per upload.
  • All of the code sits in my GitHub repo, ready to deploy to my own AWS account when it outgrows the free tiers.

What Photo Pond does

You upload a photo from a laptop or phone. Without any manual tagging, Photo Pond:

  1. Reads the capture date and GPS position from EXIF.
  2. Turns the coordinates into a place name (“Bergen, Norway”).
  3. Generates a thumbnail.
  4. Asks Claude to look at the image and assign categories, descriptive tags and a short caption.

Visitors then browse an editorial-style gallery with category tabs, year and place filters, and a full-screen carousel.

Photo Pond public gallery with category tabs and search The public gallery. Claude assigned every category tab and count you see here, and search works across tags, years and places.

Photo Pond admin dashboard The admin dashboard, behind Google sign-in: upload, manage photos, curate categories and handle private access requests.

Manage Photos table with AI-generated captions and categories Manage Photos. Claude wrote every caption and category, and I didn’t type any of them. The row with no thumbnail is a failed upload, which is easy to spot and clean up from here.

Categories admin with rename, hide and merge actions Categories. Claude reuses existing categories and only creates a new one when nothing fits. New ones stay hidden until three photos use them, and I can rename, hide or merge them.

Architecture at a glance

flowchart LR
    B[Browser] -->|pages & API| V[Vercel<br/>Next.js 16 + serverless functions]
    B -->|presigned PUT/GET| R2[(Cloudflare R2<br/>originals + thumbnails)]
    V --> N[(Neon Postgres<br/>metadata)]
    V --> A[Anthropic Claude<br/>vision classification]
    V --> G[Google OAuth<br/>admin sign-in]
    V --> U[(Upstash Redis<br/>rate limits)]
    V --> O[OpenStreetMap Nominatim<br/>GPS → place]
    GH[GitHub] -->|push to main| V

Each box is a managed service with a free tier. My job was to write the app, not to run infrastructure.

Let the free-tier limits shape the design

The most important design decision came straight from a free-tier limit. Vercel’s Hobby plan caps function request bodies at 4.5 MB, and a photo straight off a phone is often bigger than that.

So photos never pass through my server on the way in. The browser gets a short-lived presigned URL and uploads directly to object storage. It then asks the server to process the stored file:

sequenceDiagram
    participant B as Browser
    participant V as Vercel API
    participant R as R2
    participant DB as Neon
    participant C as Claude

    B->>B: SHA-256 each file
    B->>V: Already have these hashes?
    V->>DB: lookup
    V-->>B: skip exact duplicates
    B->>V: presign
    V-->>B: presigned PUT URL
    B->>R: PUT original (direct, any size)
    B->>V: notify(key)
    V->>R: fetch original
    V->>V: EXIF, geocode, thumbnail, perceptual hash
    V->>R: PUT thumbnail
    V->>DB: insert photo row
    V-->>B: done
    V->>C: classify 1024px copy (after response)
    C-->>V: categories, tags, caption
    V->>DB: update photo

I didn’t plan this pattern. The constraint forced it, and it turns out to be the right one anyway: compute stays small, uploads have no size cap, and the server only touches bytes it needs to.

The SaaS building blocks

Concern Service Why this one Free-tier limit that matters
Compute & hosting Vercel (Hobby) git push = deploy, preview URLs, built-in CDN 1M invocations, 4 h active CPU / month, non-commercial
Database Neon Postgres Real Postgres, scales to zero, branch per PR 1 GB storage, 100 CU-hours / month
Object storage Cloudflare R2 S3-compatible API, zero egress fees 10 GB stored, 1M writes, 10M reads / month
Authentication Google OAuth + NextAuth No user store to build or protect Free
Rate limiting Upstash Redis Serverless Redis over HTTP Free tier
Geocoding Nominatim (OSM) Free reverse geocoding 1 request/s (throttled and cached in code)
CI GitHub Actions Lint and type-check on every PR Free for public/personal use
AI Anthropic Claude The actual differentiator Pay-as-you-go

A few notes on each:

  • Vercel removed the deploy-pipeline project entirely. Merging to main puts the change live on photopond.techforlife.in. Every pull request gets its own preview URL.
  • Neon gives me ordinary Postgres, so I use Drizzle ORM and standard SQL with no proprietary query layer. Scale-to-zero means an idle gallery costs nothing. The tradeoff is roughly a 0.5–1 s cold start on the first request after five idle minutes.
  • Cloudflare R2 matters most for a photo site, because photo sites are egress-heavy and R2 doesn’t charge for egress. It also speaks the S3 API, so the app uses the standard AWS SDK v3. That pays off later.
  • Google OAuth handles authorisation in one line of config: only the account in ADMIN_EMAIL can sign in. There are no passwords, no user table and no reset flows.

What it actually costs

Service Plan Monthly cost
Vercel Hobby $0
Neon Free $0
Cloudflare R2 Free tier $0 up to 10 GB
Upstash, Google OAuth, Nominatim, GitHub Free $0
Anthropic Pay-as-you-go ~$0.01 per uploaded photo
Total ~$0 + ~$1 per 100 uploads

Storage is the first limit you hit. Originals are 1.5–5 MB, so R2’s free 10 GB holds roughly 2,500–4,000 photos. Even after that it’s cheap: 50 GB costs about $0.60/month. The free tier isn’t a trap. It’s a runway long enough to find out whether the idea is worth more investment.

Claude as the cataloguer

This is where the money goes, and it’s worth it.

Manual tagging doesn’t scale, and a rigid set of labels doesn’t fit a personal archive. A photo of a medieval old town is both heritage and city. A street-food stall is food, but it’s also a place and a moment. I wanted something that looks at the photo the way a person would and files it sensibly.

Structured output via a strict tool call

Each photo is downscaled to 1024 px and sent to Claude (lib/classify.ts) with a system prompt that lists the current categories. Claude has to answer by calling a tool with a strict JSON schema, so I always get machine-readable output and never have to parse free text:

const response = await client.beta.messages.create({
  model: "claude-opus-5-5",
  max_tokens: 1024,
  output_config: { effort: "low" },
  tools: [{
    name: "record_classification",
    description: "Record the categories, tags and caption for the photo.",
    strict: true,
    input_schema: {
      type: "object",
      properties: {
        categories: { type: "array", items: { type: "string" } },
        tags:       { type: "array", items: { type: "string" } },
        caption:    { type: "string" },
      },
      required: ["categories", "tags", "caption"],
      additionalProperties: false,
    },
  }],
  system: buildSystemPrompt(existingCategories),
  messages: [{
    role: "user",
    content: [
      { type: "image", source: { type: "base64", media_type: "image/jpeg", data } },
      { type: "text", text: "Classify this photo." },
    ],
  }],
});

A typical result:

{
  "categories": ["heritage", "city"],
  "tags": ["Cobblestone Street", "Old Town", "Evening Light", "Church Spire"],
  "caption": "Golden hour over the old town rooftops"
}

A taxonomy that grows without exploding

The gallery starts with eight categories: nature, city, heritage, people, animals, food, documents and other. Claude must reuse these. Only when nothing fits may it propose one new, general, one- or two-word category, such as “sports” or “vehicles”. The prompt explicitly forbids synonyms and subtypes.

New names are normalised and matched against existing ones (singular/plural, labels) before they’re saved. An AI-created category only appears on the gallery once three photos use it. I can rename, hide or merge categories from the admin page.

The result is a taxonomy that adapts to what I actually photograph, rather than one I had to design up front.

Cost engineering: don’t pay for the same photo twice

At ~$0.01 per call, the cheapest call is the one you never make:

  • Downscale first. A 1024 px JPEG is plenty for classification and keeps input tokens low.
  • Low effort. Classification doesn’t need deep reasoning, so the request sets effort: "low".
  • Exact duplicates never upload. The browser hashes each file (SHA-256) and asks the server which hashes already exist before it uploads anything.
  • Lookalikes reuse the existing classification. A 64-bit perceptual (difference) hash (lib/image-hash.ts) spots resized copies, re-compressed exports and burst shots. If a new photo is within 4 bits of one that’s already classified, it inherits that photo’s categories, tags and caption. There’s no second API call.
  • It runs off the critical path. Classification runs after the upload response returns (Next.js after()), so the uploader never waits on the model. Failures are flagged and can be retried with a script.

Switching to a smaller model would cut the per-photo cost to about a quarter, and that’s a one-line change. For now the better tags are worth a cent.

The core point: in a SaaS-first build, the AI API is the one line item you choose to pay for, because it’s the feature. Everything else is undifferentiated infrastructure, and free tiers make it disappear.

Rent now, own later: the path to AWS

SaaS-first only works as a strategy if you aren’t locked in. Every service Photo Pond uses sits behind an open or de-facto standard interface:

  • Postgres (Neon)
  • The S3 API (R2, already accessed with the AWS SDK)
  • OAuth/OIDC (Google)
  • Plain Node.js (Next.js on Vercel)
  • An HTTP API (Anthropic)

The code lives in my repo, not in a platform’s console. Here’s how it maps to my own AWS account:

Today (SaaS) On AWS Notes
Vercel (Next.js hosting) AWS Amplify Hosting, or OpenNext on Lambda + CloudFront Amplify is the shortest path; OpenNext gives more control
Vercel CDN CloudFront Same Cache-Control headers keep working
Neon Postgres Aurora Serverless v2 (PostgreSQL) or RDS for PostgreSQL Aurora Serverless v2 can scale to zero, close to Neon’s model
Cloudflare R2 Amazon S3 Same SDK; drop the custom endpoint. Watch egress (see below)
Google OAuth + NextAuth Keep NextAuth, or Amazon Cognito with Google federation Keeping NextAuth means no code change
Upstash Redis ElastiCache Serverless or a DynamoDB counter Only used for rate limiting
Anthropic API Keep the direct API, or Claude on Amazon Bedrock Bedrock keeps inference inside the AWS account and on the AWS bill
.env / Vercel env vars Secrets Manager / SSM Parameter Store
GitHub Actions GitHub Actions with OIDC to AWS No long-lived AWS keys

What changes:

  • Environment variables.
  • The S3 client endpoint (one line in lib/r2.ts).
  • The bucket CORS rule.
  • The deploy target.

What doesn’t change: the app code, the schema, the upload flow and the classification logic.

What to watch: egress. R2’s free egress is a real advantage for a photo site. On AWS, put CloudFront in front of S3 so most reads come from cache, and budget for the data transfer. This is the one place where moving off SaaS could cost more, so it should be a deliberate decision.

The honest tradeoffs

  • Non-commercial only. Vercel Hobby forbids commercial use. Going commercial means Pro ($20/month) or a move to AWS.
  • Cold starts. Neon scales to zero, and the first request after idle is slower.
  • Short safety nets. Neon’s free restore window is 6 hours. Take your own backups.
  • Vendor sprawl. That’s seven dashboards, seven sets of credentials and seven status pages. Fine for one person, but a real operational cost for a team.
  • Free tiers move. Limits change. My setup docs record the date each limit was last checked for exactly this reason.

None of these matter while you’re proving an idea. All of them matter once it works, and by then you’ll have a reason, and the code, to move.

Takeaways

  1. Let free tiers carry the idea. Database, storage, compute and auth are solved problems. Rent them for $0 and spend your time on what’s unique.
  2. Spend on the differentiator. For Photo Pond that’s Claude’s vision. A cent per photo buys automatic categories, tags and captions I’d never write by hand. Then engineer the cost down (downscale, dedupe, reuse).
  3. Choose SaaS with open interfaces. Postgres, S3 and OIDC give you an exit path. Keep the code in your repo, and “move to my own AWS account” becomes a migration, not a rewrite.

The gallery is live at photopond.techforlife.in, and the full source is at github.com/sreejith824/photogallery. Fork it, deploy it to your own free tiers, and make it yours.

The repo also includes:

  • SETUP.md: step-by-step setup for every service, plus the full costs and limits breakdown
  • docs/TECH_STACK.md: the data model, the upload pipeline and where each piece lives in the code