TL;DR
A digital goods store deployed as three decoupled services around one database: a Next.js storefront, a Keystone 6 admin with a GraphQL API served by Apollo Server, and Postgres. They run on Vercel, Google Cloud Run, and Neon, and because each service is on a managed free tier, the demo costs nothing to run.
- Code: https://github.com/vavilov2212/digital-products-shop
- Live shop: https://digital-products-shop.vercel.app
- Roadmap: connect a real product supplier API, then build the payment flow.
The rest of this article is the architecture and the full deployment, one host at a time.
Table of contents
- The architecture, split into three services
- What Keystone actually is
- Service 1: the storefront (Next.js on Vercel)
- Service 2: the admin and GraphQL API (Keystone 6 on Google Cloud Run)
- Service 3: the database (Postgres on Neon)
- Local development with Docker Compose
- The production container (multi-stage Docker build)
- CI/CD with GitHub Actions
- How a request is served, and what a deploy does
- Standing it up from scratch
- Environment variables
- Locking down the public GraphQL endpoint
- Takeaways
I built a small store for digital goods and put it into production on three managed free tiers. The interesting part is not the shop itself. It is how the pieces are split into independent services and wired together so each one can scale, sleep, and deploy on its own.
The stack:
- Next.js 13 (App Router) for the storefront
- Keystone 6 for the admin panel and a public GraphQL API
- Postgres for storage
- Docker for the containers and Docker Compose for local development
- GitHub Actions for CI/CD
Hosted on Vercel, Google Cloud Run, and Neon.
The architecture, split into three services
The system is three services with one shared database. Each service has one job.
a user
|
v
+--------------------------------------------+
| Vercel - storefront (Next.js, ISR) |
| static pages, revalidate at most once/day |
+--------------------------------------------+
|
queries Postgres directly, through an
embedded Keystone/Prisma context
(never calls the admin service)
|
v
+--------------------------------------------+
| Neon - Postgres (serverless) |
| single source of truth |
+--------------------------------------------+
^
admin writes + Prisma migrations
|
+--------------------------------------------+
| Google Cloud Run - Keystone admin |
| + public GraphQL API, scale to zero |
+--------------------------------------------+
💡 The load-bearing decision. The storefront reads Postgres directly
through Keystone's embedded context and never calls the admin service. That one
choice is what makes the free-tier setup work: the admin service is allowed to
scale to zero and sleep, and its cold starts never touch a user request.
What Keystone actually is
Before the service breakdown, it helps to know what Keystone is under the hood, because it is doing more than one job. Keystone is a bundle of tools you would otherwise wire up yourself, all generated from a single schema:
- Prisma as the ORM and migration engine
-
Apollo Server for the GraphQL API (configured right in
keystone.tsviaapolloConfig) - A generated Admin UI built on React and Next.js
- Session and access-control primitives
You could assemble these separately, a Prisma schema plus a hand-written Apollo Server plus your own admin pages, and some teams do. Keystone's pitch is that it generates all of it from one list schema so you do not have to.
Keystone can also run in two shapes, and this project uses both:
- Standalone: Keystone runs its own server for the Admin UI and the GraphQL endpoint. That is what runs on Cloud Run here.
-
Embedded: Keystone's data layer is imported into another Node or Next.js process through
getContext, so you can run Keystone queries in-process without going through the GraphQL server over the network.
The storefront uses the embedded shape to read Postgres directly, while the Admin UI and the Apollo GraphQL server run standalone on Cloud Run. That split is the whole trick, and the next three sections walk through each piece.
Service 1: the storefront (Next.js on Vercel)
The storefront is a Next.js 13 App Router app on Vercel. The product, category, and cart pages are rendered with ISR, which stands for Incremental Static Regeneration. Each page is built once into static HTML and then allowed to rebuild itself on a schedule, so it stays fast to serve and does not go stale forever. The schedule here is revalidate: 86400, so 24 hours at most.
The important detail is how the storefront gets its data. It does not call the Keystone GraphQL server over the network. It imports Keystone's embedded context and runs queries against Postgres in-process. The whole product fetch is a direct database query with no external call:
export async function getProducts() {
return await keystoneContext.sudo().query.Product.findMany({
orderBy: { productId: 'asc' },
query: `id productId label description price imageUrl currency { id code name }`,
});
}
Because this runs at build time and during background revalidation, the data in the live pages is a snapshot from the moment of the last build. That fact matters during a deploy.
Why Vercel: it runs Next.js with almost no configuration, the CDN and ISR caching are built in, and it redeploys on every push to master through its GitHub integration.
Service 2: the admin and GraphQL API (Keystone 6 on Google Cloud Run)
Keystone 6 gives me two things from one schema: an admin UI for editing products, and a GraphQL API. GraphQL is a query language where the client asks for exactly the fields it wants in one request. In this project the GraphQL playground and schema introspection are intentionally left public, so the API is browsable as part of the demo.
Keystone runs as a Docker container on
Google Cloud Run. Cloud Run is Google's
serverless container platform: you hand it an image and it runs it, scaling the number of instances up and down with traffic, including down to zero. Scale to zero means that when nobody is using the admin, Cloud Run stops the container and charges nothing. The first request after an idle period pays a cold start, a couple of seconds here while the container boots.
That cold start would be unacceptable if users depended on this service. They do not, because the storefront reads Postgres directly. Only I use the admin, so a two-second wake-up on login is fine.
Two things run on every container boot:
-
Prisma migrations
(
prisma migrate deploy), which apply any pending versioned schema changes so the database shape matches the code. - An
onConnecthook that optionally seeds demo data, gated behind aSEED_ON_STARTUPenvironment variable.
onConnect: async context => {
if (process.env.SEED_ON_STARTUP === 'true') {
await seedDemoData(context);
}
},
The service is named keystone-admin in the us-central1 region, pinned to a maximum of one instance so migrations never run concurrently.
Why Cloud Run: it runs any container, it only bills while the container is awake, scale to zero costs nothing at rest, and it deploys from a GitHub Action on every push to master.
Service 3: the database (Postgres on Neon)
Neon is serverless Postgres.
It is the single source of truth for products, categories, currencies, carts, and orders. Both other services talk to it.
Neon also suspends the compute after about five minutes of no queries and resumes in roughly half a second to two seconds on the next query. For a demo this is invisible almost all the time.
Neon hands you two connection strings, and matching the right one to the right service is load-bearing.
💡 Pooled vs direct, and where each string goes.
The pooled string (its host contains-pooler) routes through
PgBouncer, which lets many short-lived requests
share a small set of real database connections. It goes to the storefront on
Vercel, with?sslmode=require&pgbouncer=true&connect_timeout=15appended,
because the storefront opens many short connections.
The direct string goes to Cloud Run, because
Prisma migrations do not work through the pooler
and Keystone runs migrations on boot. Swap the two and migrations fail on boot
in confusing ways.
Why Neon: real Postgres, a genuine free tier (0.5 GB storage, 100 compute-hours a month), and fast enough resume that the suspend does not hurt.
Local development with Docker Compose
Production is three separate hosts, but locally the whole thing comes up with one command through Docker Compose. Docker Compose runs a set of containers defined in one YAML file. docker-compose.dev.yml defines three services that mirror production:
services:
nextjs-front: # Next.js storefront on :3000, ./src and ./public mounted for hot reload
keystone-server: # Keystone admin + GraphQL on :4000, SEED_ON_STARTUP=true
db: # postgres:latest on :5432, data in ./.dbdata
Bring it all up:
docker compose -f docker-compose.dev.yml up -d
# storefront: http://localhost:3000
# admin: http://localhost:4000
# postgres: localhost:5432
Two details make the dev loop pleasant:
- The storefront container mounts
./srcand./publicas volumes, so front-end edits hot-reload without a rebuild. - The Keystone service sets
SEED_ON_STARTUP=true, so a fresh database is populated with the demo catalog on first boot.
The local Postgres uses POSTGRES_HOST_AUTH_METHOD: trust because it is never exposed outside the compose network.
The production container (multi-stage Docker build)
The Keystone image is a multi-stage Docker build. Multi-stage means the build runs in phases and only the final phase ships, so build-time tooling does not bloat the runtime image. The stages here are base, deps, builder, and runner:
FROM node:20-slim AS base # Debian slim, openssl installed
FROM base AS deps # npm ci --legacy-peer-deps
FROM base AS builder # npm run keystone:build
FROM base AS runner # CMD ["npm", "run", "keystone:start"]
One line in that Dockerfile is a scar from a real bug.
⚠️ Use the Debian (
slim) base, not Alpine.
Prisma's Alpine (musl) query engine links against OpenSSL 1.1 and segfaults
inside Node 20, which ships OpenSSL 3, during TLS handshakes. A TLS handshake
happens on everysslmode=requireconnection, which is exactly what Neon
requires. So an Alpine image builds fine and then crashes the moment it tries
to reach the database.node:20-slimis Debian and ships OpenSSL 3 engines,
which fixes it. This cost real debugging time and is not something you would
guess from the error.
One more Cloud Run detail lives in this image: PORT is deliberately not set.
Keystone reads process.env.PORT at runtime, and Cloud Run injects PORT=8080.
Letting the platform pick the port is part of the
Cloud Run container contract.
CI/CD with GitHub Actions
Two GitHub Actions workflows drive continuous integration and deployment.
ci.yml runs on pull requests and on pushes to dev. It generates the Keystone and Prisma types without a database (keystone postinstall), type-checks with tsc, and runs the linter. The lint step is allowed to fail without blocking, because the repo carries some known stylistic debt. This workflow does not deploy.
deploy-keystone.yml runs on pushes to master. It builds the Keystone
container, pushes it to Artifact Registry (Google's container image store), and deploys it to Cloud Run:
on:
push:
branches: [master]
steps:
- uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- run: docker build -f docker/production/Dockerfile.keystone -t "$IMAGE:${{ github.sha }}" .
- run: docker push "$IMAGE:${{ github.sha }}"
- run: gcloud run deploy keystone-admin --image "$IMAGE:${{ github.sha }}" --region us-central1 ...
Runtime environment variables are set once on the Cloud Run service and preserved across deploys, so the workflow never has to know any secrets beyond the two it needs to authenticate: GCP_PROJECT_ID and GCP_SA_KEY.
How a request is served, and what a deploy does
Two flows tie the services together.
A user request:
- The user asks Vercel for a page.
- Vercel serves the pre-built static HTML instantly.
- If the page is past its 24-hour window, Vercel rebuilds it in the background on the next request, querying Neon directly.
Cloud Run never appears in that path. That is the whole point.
A deploy, triggered by one push to master:
git push origin dev:master
|
+------+------------------------+
| |
v v
Vercel rebuilds GitHub Action
the storefront deploy-keystone.yml
(queries Neon, bakes builds + deploys the
current data into pages) Keystone container to Cloud Run
Production is the master branch. Work happens on dev, which is not
production. The two deploys run independently and do not finish at the same time, so a fresh storefront build can briefly ship with data that is about to change underneath it.
Getting work onto master is a fast-forward push. A fast-forward is the clean git case where master is simply behind dev with no divergent history, so it slides forward with nothing to merge:
git fetch origin
git merge-base --is-ancestor origin/master dev && echo yes # prints yes if safe
git push origin dev # update remote dev
git push origin dev:master # fast-forward master to dev
You can watch the Cloud Run deploy from the terminal:
gh run list --branch master --workflow "Deploy Keystone to Cloud Run" --limit 3
gh run watch <run-id> --exit-status
Standing it up from scratch
Everything you need to reproduce the deployment, one host at a time.
Neon
- Create a free project at neon.tech, in an AWS region near Cloud Run and Vercel (us-east or us-central).
- Copy both connection strings from the dashboard. The direct one
(
...@ep-xxx.region.aws.neon.tech/neondb?sslmode=require) goes to Cloud Run. The pooled one (its host contains-pooler) goes to Vercel, with Prisma params appended:?sslmode=require&pgbouncer=true&connect_timeout=15.
Google Cloud Run
Billing must be linked, though usage stays inside the always-free tier at demo traffic. Every command below also runs from Cloud Shell in the browser, which comes pre-authenticated, so no local CLI is required.
gcloud projects create <PROJECT_ID> && gcloud config set project <PROJECT_ID>
gcloud services enable run.googleapis.com artifactregistry.googleapis.com
gcloud artifacts repositories create shop --repository-format=docker --location=us-central1
First deploy, which seeds the catalog because SEED_ON_STARTUP=true is set:
gcloud auth configure-docker us-central1-docker.pkg.dev
docker build -f docker/production/Dockerfile.keystone \
-t us-central1-docker.pkg.dev/<PROJECT_ID>/shop/keystone:initial .
docker push us-central1-docker.pkg.dev/<PROJECT_ID>/shop/keystone:initial
gcloud run deploy keystone-admin \
--image us-central1-docker.pkg.dev/<PROJECT_ID>/shop/keystone:initial \
--region us-central1 --min-instances 0 --max-instances 1 \
--memory 512Mi --cpu 1 --allow-unauthenticated \
--set-env-vars "DATABASE_URL=<NEON_DIRECT_URL>,SESSION_SECRET=<openssl rand -hex 32>,STORAGE_KIND=local,PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK=true,SEED_ON_STARTUP=true,KEYSTONE_PUBLIC_URL=<filled after first deploy>"
Right after the first deploy, confirm the catalog exists, then remove the seed flag and set the real public URL:
gcloud run services update keystone-admin --region us-central1 \
--remove-env-vars SEED_ON_STARTUP \
--update-env-vars KEYSTONE_PUBLIC_URL=https://<service-url>
Then set a $1 budget alert in Billing so any accidental spend pings you early.
⚠️ Close the
initFirstItemwindow immediately. Keystone lets anyone create
the first admin user while the User table is empty. Open the service URL and
create yours right after the first deploy, so nobody else can.
Note: the container filesystem is ephemeral, so admin-uploaded images
(STORAGE_KIND=local) disappear on the next cold start. The catalog itself lives in Neon and in repo-shipped static images, so it is unaffected. Wire S3 or R2 and STORAGE_KIND=s3 if uploads ever need to persist.
Vercel
- Import the GitHub repo at vercel.com/new with the Next.js preset.
- Override the build command to
npx keystone postinstall && next build. The first half generates the Keystone and Prisma types without a database; the second half prerenders the pages, which does query Neon, so the pooledDATABASE_URLmust be present at build time. - Set the environment variables (next section).
- Vercel then deploys on every push through its GitHub integration.
GitHub Actions secrets
Create a deploy service account, grant it the three roles the workflow needs, and export a key:
gcloud iam service-accounts create github-deploy
for role in run.admin artifactregistry.writer iam.serviceAccountUser; do
gcloud projects add-iam-policy-binding <PROJECT_ID> \
--member serviceAccount:github-deploy@<PROJECT_ID>.iam.gserviceaccount.com \
--role roles/$role
done
gcloud iam service-accounts keys create key.json \
--iam-account github-deploy@<PROJECT_ID>.iam.gserviceaccount.com
Add GCP_PROJECT_ID (the project id) and GCP_SA_KEY (the contents of
key.json) as repository secrets, then delete the local key file.
Environment variables
Set in three places, and some values must match across them.
| Variable | Vercel | Cloud Run | Local | Notes |
|---|---|---|---|---|
DATABASE_URL |
pooled | direct | optional | pooled for the storefront, direct for migrations |
DB_* (name/user/password/host/port) |
- | - | yes | dev docker fallback when no DATABASE_URL
|
SESSION_SECRET |
yes | yes | optional | same value on both hosts (openssl rand -hex 32) |
REVALIDATE_SECRET |
yes | - | optional | guards /api/revalidate and /api/sync-supplier
|
KEYSTONE_PUBLIC_URL |
- | yes | - | public base URL for Keystone-served images |
PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK |
- | true |
- | needed for Neon migrations on boot |
SEED_ON_STARTUP |
- | temporary |
true (dev) |
flip on to seed, then remove |
STORAGE_KIND |
local |
local |
local |
local or s3 for admin uploads |
The pair that trips people up is DATABASE_URL: pooled on Vercel, direct on Cloud Run.
⚠️
SESSION_SECRETmust be identical on Vercel and Cloud Run. The Vercel
build imports the auth code that reads it, so a mismatch breaks the build, not
just runtime sessions.
Locking down the public GraphQL endpoint
The Keystone GraphQL API on Cloud Run is reachable from the public internet. That raises two separate questions: who is allowed to read the data, and who is allowed to hit the endpoint at all. I handled the first one in code. The second one has a range of options, and I stopped at the point that is enough for a demo.
What I did: access control in the schema
Keystone has a per-list access control API, and I used it to session-gate everything that holds personal data:
-
User,Order,Cart, andCartProductnow require an admin session for every query and mutation. An anonymous caller on the public endpoint can no longer read customer emails, enumerate users, or forge and delete orders. -
Product,Category, andCurrencystay publicly queryable, because they are the read-only catalog and hold no personal data. Their writes are admin-only.
I also disabled the GraphQL playground and schema introspection in production. In local development Keystone serves a playground and lets you introspect the schema, which is convenient. In production both are off, so the endpoint does not advertise its shape to strangers.
None of this touched the storefront. It reads and writes through the in-process Keystone context, which runs as an admin and bypasses access control, so tightening the public endpoint changed nothing for users.
💡 Access control is the real lock, not CORS or a hidden playground.
Turning off the playground and introspection is defense-in-depth: it stops the
endpoint from advertising its schema, but a determined caller withcurldoes
not need the schema to start guessing. What actually protects the data is
per-list access control. CORS only restricts cross-origin JavaScript in a
browser and does nothing to a bot or a script.
Going further: taking the endpoint off the public internet
For a demo, the access control above is enough, and I stopped there. If this took real traffic, or if bots hammering /api/graphql became a nuisance, the next step is to stop anonymous requests from reaching the service at all. Because the storefront never calls Cloud Run over HTTP, the whole service can be made private without breaking anything. The options run from free to paid:
-
Cloud Run IAM auth plus an authenticated proxy (free). Make the service private with
--no-allow-unauthenticated, and anonymous traffic is rejected at the ingress before a container even starts, so bot spam costs no instance time. To use the admin you open a local proxy signed in as your Google account. The trade-off is that there is no public, bookmarkable admin URL. -
A load balancer with IAP or Cloud Armor (paid). A load balancer is a managed entry point that receives all traffic and forwards it to the service, and on GCP it is also where the two features below attach. It has no free tier, so this route starts around $18 a month whether or not anyone visits.
- Identity-Aware Proxy (IAP) puts a Google sign-in gate in front of a public URL, so only identities you allow can reach the app. This is the choice if you want a public admin URL protected by login.
-
Cloud Armor is Google's web application firewall (WAF) and rate-limiting layer. You can allow only your own IP, or throttle requests per IP, for example 60 a minute, so a bot hammering
/api/graphqlgets rejected with a 429. This is the direct answer to endpoint spam.
| Option | What it does | Rough cost |
|---|---|---|
| Cloud Run IAM + local proxy | Rejects all anonymous traffic at the ingress; admin via an authenticated proxy | $0 |
| IAP | Google sign-in gate in front of a public admin URL | ~$18/mo (the load balancer) |
| Cloud Armor | WAF and per-IP rate limiting on the endpoint | ~$18/mo + policy and per-request fees |
For now the endpoint stays publicly reachable, the data is protected by the access control in the schema, --max-instances 1 bounds the cost of any abuse, and the $1 budget alert would surface it. That is a deliberate stopping point, not an oversight.
Takeaways
The whole thing rests on one architectural decision: the storefront reads the database directly through Keystone's embedded context instead of calling the admin service. That decoupling is what lets the admin scale to zero, lets each service deploy on its own, and keeps the bill at zero for a demo.
Everything else follows from taking that separation seriously:
- the multi-stage Docker build and the Debian base that keeps Prisma alive on TLS
- the pooled versus direct Neon connection strings
- the seed-on-boot hook gated behind an environment variable
- the CI/CD split between
devandmaster
If you want to poke at it:
Top comments (0)