arrow_backWriting

Build a Self-Updating NASA Photo Gallery on AWS for a Penny a Month

The idea

NASA publishes a new Astronomy Picture of the Day (APOD) every day, with a free public API. I wanted my portfolio site to show those images in a gallery that updates itself: no CMS, no rebuilds, no GitHub commits, no human in the loop.

The result has been running in production for over a year at a total cost of under $0.01 per month. You can see it live: the APOD gallery on this site. This is the full recipe.

The architecture

Three AWS pieces and one static page:

  1. Amazon EventBridge fires once a day on a schedule.
  2. AWS Lambda (Python) calls NASA's APOD API, downloads the image, and writes everything to S3.
  3. Amazon S3 stores the images plus small JSON index files that describe the archive.
  4. The static page fetches those JSON indexes with client-side JavaScript and renders the grid. The site itself never rebuilds.

The key design decision is that the Lambda maintains indexes, not just images. The page needs to answer "what images exist?" without listing the bucket from the browser, so the function writes three kinds of JSON:

  • archive-index.json: the most recent ~100 images, newest first
  • archive/monthly-indices.json: the list of months that have archives
  • archive/monthly/YYYY-MM.json: one index per month, for filtering

Each entry carries the image URL plus a pointer to a per-image metadata file with NASA's title, explanation, and copyright. That metadata is what makes the gallery feel finished: the image viewer can show the full APOD description without any extra infrastructure.

The Lambda

The core of the function is about thirty lines. Get an API key at api.nasa.gov (the free tier is far more than one call per day needs).

import json
import urllib.request
import boto3

BUCKET = "your-apod-bucket"
API_KEY = "YOUR_NASA_API_KEY"

s3 = boto3.client("s3")

def handler(event, context):
    # 1. Ask NASA for today's picture
    with urllib.request.urlopen(
        f"https://api.nasa.gov/planetary/apod?api_key={API_KEY}"
    ) as r:
        apod = json.load(r)

    if apod.get("media_type") != "image":
        return {"skipped": "today is a video"}

    date = apod["date"]

    # 2. Archive the image
    with urllib.request.urlopen(apod["url"]) as img:
        s3.put_object(
            Bucket=BUCKET,
            Key=f"archive/{date}.jpg",
            Body=img.read(),
            ContentType="image/jpeg",
        )

    # 3. Archive the metadata next to it
    s3.put_object(
        Bucket=BUCKET,
        Key=f"archive/{date}.json",
        Body=json.dumps({
            "title": apod["title"],
            "explanation": apod["explanation"],
            "date": date,
            "copyright": apod.get("copyright", ""),
            "url": apod["url"],
        }),
        ContentType="application/json",
    )

    # 4. Rebuild the indexes (read, prepend, write back)
    update_indexes(date, apod["title"])

update_indexes reads the current archive-index.json, prepends the new entry, rewrites it, and does the same for the month file. Because only this one function ever writes the indexes, and it runs once a day, there are no concurrency problems to solve. That is most of the charm of the design: the hard parts of a dynamic gallery (consistency, caching, invalidation) disappear when one writer updates a few small JSON files on a schedule.

Schedule it with an EventBridge rule:

rate(1 day)

The page

The gallery page is plain HTML plus a fetch:

const INDEX_URL = "https://your-apod-bucket.s3.amazonaws.com/archive-index.json";

const res = await fetch(INDEX_URL);
const { images } = await res.json();

for (const image of images) {
  // render a card with image.thumbnail, image.title, image.date
}

For the viewer, I use a native <dialog> element rather than a lightbox library: showModal() gives you focus trapping, Escape-to-close, and a backdrop for free, and the per-image metadata JSON supplies NASA's explanation text. The whole gallery runs with zero JavaScript dependencies.

Two production details worth copying:

  • CORS: the bucket needs a CORS policy that allows GET from your domain, or the browser will block the index fetches. I scope mine to exactly one origin (my own site), which also means the data can't be hot-loaded from other pages.
  • Date parsing: new Date("2026-06-09") is parsed as UTC midnight, so it can display as the previous day in western timezones. Split the string and construct the date from parts instead.

What it costs

Item Monthly
Lambda (30 invocations, ~5s each) $0.00 (free tier)
EventBridge rule $0.00
S3 storage (~1 GB after a year) ~$0.005
S3 requests ~$0.001

Under a penny. There is no scenario where a server, a database, or a CMS earns its keep for a workload like this: one write per day, reads served as static objects.

Why I like this pattern

This is the smallest useful example of a pattern I keep returning to in much larger systems: a scheduled function that does its work autonomously and leaves behind plain, fetchable artifacts. The same shape (EventBridge, Lambda, structured output to S3, no human in the loop) runs serious production workloads; I wrote about a bigger application of it in Event-Driven AI Pipelines. The gallery is the version you can build in an afternoon.


arrow_backAll writing

arrow_upward