Skip to content
The Cloud Guy
ATC-005 AWS Guide

Google Drive as the back office: a private photo gallery on AWS for about a dollar a month

Keep Google Drive as the source of truth without putting it in the request path. Read-only sync, content-versioned S3 keys and CloudFront signed cookies.

Published
Reading time
8 min
On this page
  1. The constraints
  2. Three ways to put a website on Drive
  3. Reading Drive safely
  4. The folder tree is the content model
  5. The refresh: plan, diff, copy, publish
  6. Serving: no code runs per photo
  7. A bonus: copyright without touching a pixel
  8. What it costs
  9. What I’d take to any “Drive as CMS” project

I shoot photos for a youth sports team. The workflow was already set: edit, export, and upload to a Google Drive folder per game. The families wanted one thing, which was to browse the photos on their phones without a pile of shared links.

The tempting answer is to put a website in front of Drive. This is the design I ended up with instead, and the one idea it rests on applies well beyond photos: use Drive as the back office, never as the shop floor.

The constraints

Four constraints shaped every decision, in this order:

  1. Privacy. These are photos of minors. Nothing public, nothing indexable, no “anyone with the link” sharing, and no Drive identifiers in the browser.
  2. Cost. It’s a volunteer project, so no always-on compute and no database.
  3. Phones on stadium wifi. Galleries of 700 photos have to scroll smoothly on a bad connection.
  4. Maintainability. One volunteer should still be able to run it in three years.

Three ways to put a website on Drive

Approach What happens per photo view Problem
Share Drive links The browser loads from Google Breaks the privacy rule outright
Proxy Drive through a Lambda A function call and a Drive API call Drive’s quota and latency in every page view; a gallery of 700 tiles is 700 invocations
Sync Drive into S3, serve from S3 A cached file from the edge Storage costs a little more; the sync has to be right

The third option wins on every constraint. The price is that you have to build the sync carefully, which is what most of this article covers.

refresh: when the photographer runs it Google Drive source of truth drive.readonly Refresh job plan, diff, copy changes S3, private originals, previews, thumbs, manifests browse: every visit Phone CloudFront one hostname /api/* /photos/* API Lambda sign-in, manifests S3 via the edge signed cookies 1 call per page no code per image
Drive sits only on the refresh path. Nothing a visitor does can reach it.

The dashed line is the whole design. Drive is contacted only when I run a refresh. After that, every byte the site serves (the catalog, the gallery manifests, the thumbnails, previews and full-size downloads) comes from S3 through CloudFront. There is no code path from a page view to Google.

Reading Drive safely

A service account with the read-only scope. The refresh job authenticates as a Google Cloud service account scoped to drive.readonly. It needs no role in the Google Cloud project. Its only access comes from sharing the photo folder with the service account’s email address, the same way you’d share with a person. To revoke access, remove it from the folder.

Ask for as little as possible. Drive will happily return a photo’s full EXIF data, GPS location included. The client asks for exactly the fields it uses:

const FILE_FIELDS =
  'id,name,mimeType,description,size,md5Checksum,createdTime,modifiedTime,' +
  'imageMediaMetadata(width,height,rotation)';

Narrowing the image metadata to width, height and rotation means location data is never fetched, cached or served. Privacy by not asking beats privacy by filtering. And description rides along on the same listing call, which matters later.

Skip the giant SDK. The job needs three Drive endpoints: list a folder, get a file, download a file. It calls them with plain fetch, using Google’s small auth library for the token, instead of the all-in-one googleapis package. The smaller bundle made Lambda cold starts noticeably faster.

Two credentials, on purpose. I also have local tools that write to Drive: they create the folder for a new game, edit descriptions and upload photos. They use a completely separate credential: an OAuth desktop client that I authorize myself, with its token kept in the macOS Keychain.

Used by Scope Stored in Writes?
Refresh job drive.readonly Secrets Manager No
Local tools drive Keychain Yes

With one credential that could do both, “the AWS side never modifies Drive” would only be true as long as nobody wrote code that calls the update API. With two, a Lambda can’t modify Drive even if its code is wrong, because it never holds a credential that could. That turns a promise into a property of the deployment.

One wrinkle: drive is one of Google’s restricted scopes. Publishing an app that uses it requires a paid security assessment, so the local client stays in testing mode, where refresh tokens expire after seven days. The tools treat that as “click Allow again”, which happens about once per game. The narrower drive.file scope doesn’t expire, but it can only see files the app created itself, which is useless for an archive that already exists.

The folder tree is the content model

There’s no admin interface and no database. The Drive folder tree is the content model:

Team root            ->  /                   one card per sport
  Sport              ->  /sport              one card per game
    2026-08-08 - …   ->  /sport/{game}       one card per photo set
      …-highlights   ->  /sport/{game}/{set} the photo grid

A single rule does most of the work: a game is the topmost folder below the root whose name starts with a date. Photographers date the folder for the game, not for the sport or the season, so the date is the one reliable marker. Taking the topmost dated folder matters because set folders often repeat the game’s date. A rule that looked only at a folder’s own name would promote every set to a game.

Titles, locations and running order come from Drive’s folder descriptions: right-click a folder, choose Folder information, and type. That’s the CMS. Since description arrives with the folder listing, the site’s editorial content costs zero extra API calls. A small committed config file handles the rest, like display names and folders to hide.

The refresh: plan, diff, copy, publish

The refresh is the only part with real logic, and it runs in four steps.

  1. Plan. Walk the folder tree and read metadata only. No photo is downloaded.
  2. Diff. Compare against the state the previous refresh published. An unchanged photo costs nothing at all: no download, no resize, no upload.
  3. Copy what changed. Download each new or changed photo once. Write the original plus two WebP derivatives to S3: a roughly 480-pixel thumbnail for the grid and a roughly 1,800-pixel preview for full-screen viewing.
  4. Publish. Write the gallery manifests first, then the catalog, then the new state. Sweep orphaned objects only afterwards.

Three details make it robust.

Keys that never change meaning. Every photo is stored under photos/{photoKey}/{version}/….

  • photoKey is a one-way SHA-256 hash of the Drive file ID. It stays the same for the life of the photo, survives folder renames, and tells the browser nothing about how Drive is organized.
  • version is a hash of Drive’s checksum for the file. A re-edited photo gets a new version, so it lands on a new URL.

That means every photo URL serves the same bytes forever. The site can cache every image for a year, marked immutable, and never needs a CloudFront invalidation. The manifests travel through the uncached API path, so a refresh shows up on the next page load.

Nothing half-finished goes live. Because the manifests are written only after every object they reference exists, a refresh that dies midway leaves the previous gallery fully intact. A photo that fails to import is left out of the manifest and logged. If more than 5% of photos fail (with a floor of 20), nothing is published at all, because a gallery missing that much isn’t one anyone asked for.

It fits in Lambda. A season of 4–5 MB photos doesn’t fit in one Lambda invocation. The job processes six photos at a time. When less than 90 seconds of run time remain, it saves its position to S3 and invokes itself again. The first import of 778 photos took about 19 minutes across several invocations. A refresh with no changes in Drive moves zero bytes and finishes in seconds.

Serving: no code runs per photo

The site uses one shared team password, hashed with scrypt and checked by a small Lambda. Logging in sets two things:

  • a signed session cookie for the API
  • CloudFront signed cookies scoped to /photos

From then on, CloudFront checks each image request’s signature at the edge and serves the file from cache, or from the private bucket on a miss. No Lambda runs per image, and every signed-in viewer shares the same cached copy. A 700-photo gallery costs one API call and 700 static file fetches.

How to protect each image Cost per image Edge caching
Lambda checks the session and streams the file One invocation Not possible
CloudFront Function verifies a token A small per-request fee Yes
CloudFront signed cookies Nothing Yes

Every full-size download carries the photographer’s credit and copyright in its metadata. The obvious tools would have re-encoded the JPEG to add a text field, losing quality on every photo in the archive. Instead, the code treats this as a byte-level edit. EXIF, IPTC and XMP metadata live in separate segments next to the compressed image data, not inside it. So the job rewrites those segments and copies everything from the start of the image data onward verbatim. The photograph’s pixels are provably unchanged, and there’s no native dependency to break on ARM Lambda.

What it costs

With nobody visiting, the bill is about $1.04 a month:

  • AWS Secrets Manager: $0.80 for two secrets. That’s the floor of the whole bill.
  • S3 storage: about $0.12.
  • Logs: a few cents.

CloudFront, API Gateway and Lambda cost nothing at rest. The Drive API is free at this volume.

When people do visit, the biggest variable is CloudFront data transfer, and the biggest lever on it is thumbnail size. Thumbnails of about 480 pixels in WebP keep a full scroll through a large gallery near 30 MB. If the grid loaded originals, the same scroll would be around 4 GB.

What I’d take to any “Drive as CMS” project

  • Keep Drive out of the request path. Sync on demand, and serve from storage you control.
  • Ask Drive for the fields you use, and nothing else. Location data you never fetch can never leak.
  • Separate read and write credentials. Make “the cloud side can’t change the source” true by construction.
  • Use content-versioned keys. Same URL, same bytes, forever: no cache invalidation, ever.
  • Publish last. A failed sync should leave yesterday’s site, not a broken one.
  • Let the folder tree and folder descriptions be the content model before you reach for a database.

One thing I’d consider changing: the refresh job authenticates with a service-account JSON key stored in Secrets Manager. Google’s Workload Identity Federation supports AWS as an identity source. That would let the Lambda trade its own AWS credentials for a short-lived Google token, with no long-lived key to store or rotate. For a project this size the key works fine, but it’s the one long-lived secret in the design.


Sources: Google Drive API files reference, Workload Identity Federation, CloudFront signed cookies.

  • aws
  • google-drive
  • cloudfront
  • s3
  • serverless
  • cost

Related

ATC-004 Azure, guide

Same agent, two clouds: when one agent isn't enough

Multi-agent workflows on AWS and Azure. Strands, Step Functions and A2A versus Microsoft Agent Framework, Durable Task and A2A, and when not to use any of them.

Have a project in mind?

Let's see if I can help.

Send a message