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:
- Amazon EventBridge fires once a day on a schedule.
- AWS Lambda (Python) calls NASA's APOD API, downloads the image, and writes everything to S3.
- Amazon S3 stores the images plus small JSON index files that describe the archive.
- 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 firstarchive/monthly-indices.json: the list of months that have archivesarchive/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
GETfrom 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.