152 2026-09-17 · 20 MIN · LONG-FORM

Don't Pay to Host Your Hobby

fewbottles.com is GitHub Pages, Cloudflare, four JSON files, and a bitmask in localStorage

Diagram · 152
flowchart LR
  Phone["Phone / browser"]
  CF["Cloudflare<br/>DNS, TLS, CDN"]
  GH["GitHub Pages<br/>origin"]
  LS[("localStorage")]
  JSON["data/*.json"]
  Phone -->|"fewbottles.com"| CF
  CF --> GH
  GH --> JSON
  Phone --> LS

fewbottles.com is a working cocktail app that costs me a domain name and nothing else. GitHub Pages is the origin. Cloudflare is the front door. The only state that matters lives in the phone that opened the page. I do not rent a server, I do not run a database, and I do not have an account system, because the menu is four JSON files and the shelf is a bitmask.

If you are paying a host for a site that could have been files, the distance between that bill and zero is not a feature. It is a computer you did not need.

This post is not a GitHub Pages tutorial, and it is not a cocktail book. It does not walk through clicking Enable Pages in a repository settings screen, and it does not convert an existing WordPress site. It is how this one site is built, why it does not need a server, and what that constraint forced into the data model. The source is github.com/cjimti/drink, MIT licensed, currently shipping v1.6.1.

§A printed menu, then a site

I designed a paper menu for the bar at home, bought a sleeve, and handed it to guests. The only things I have to keep fresh are a few pieces of citrus and the syrups I rotate monthly. With a modest bar I could make fifty to seventy-five classics, and the card wrote each one as a short code in the margin: 2,1,q,3,10,2b,R. That is a Pedro Martinez if you know the house shorthand, and a mystery if you do not.

I wanted three things the paper could not do. I wanted to tick the bottles I actually own and see what I can pour tonight. I wanted to see that one more bottle, bought tomorrow, adds seven drinks rather than sitting in twelve recipes I still cannot make. And I wanted the menu I hand a guest to be only what I can pour right now, so when they ask which of these I can make, the answer is all of them.

That is the whole product. I put it on the internet because I wanted those features on my phone, standing in front of the bottles, and I might as well share them. It is not a business.

§The bill

A .com from any registrar. That is the only invoice. GitHub Pages is free on a public repository. Cloudflare’s Free plan is free. The menu is static files, so the Workers request limit does not apply: nothing at the edge has to run for a page to come back.

Netlify, Vercel, Cloudflare Pages, S3 plus CloudFront, and a small VPS would all host the same files. Cloudflare Pages in particular would skip GitHub Pages entirely: same static objects, unlimited bandwidth for those objects on the free plan, a Worker if I later wanted one. I did not pick it. The source is already on GitHub, Actions already runs the checks, and Pages is the origin GitHub already operates. Cloudflare sits in front of that origin for DNS, TLS, and a CDN. I run Kubernetes clusters for a living. I would rather not operate one for a menu.

GitHub’s own Pages limits are the honest ceiling, and they are generous for a hobby: a published site may be no larger than 1 GB, and there is a soft bandwidth cap of 100 GB a month. If you blow through that, GitHub Support’s own suggestion is to put a third-party CDN in front. That is what Cloudflare is doing here. The files this origin serves, scripts, styles, JSON, and the drink pages, are well under a megabyte before the glass drawings and share cards. A cocktail menu will not be the site that makes GitHub send that email.

GitHub Pages is also not a free host for a store. The terms bar using it as commercial SaaS or for sensitive transactions. This site has no accounts, no payments, and no password fields. A content blocker stops the two analytics scripts and the menu still works. That is the kind of site Pages is for.

§GitHub Pages is the origin

The custom domain is fewbottles.com. A CNAME file in the repo names it:

fewbottles.com

GitHub Pages is configured to deploy from a workflow, not from the docs/ folder or a gh-pages branch, and only a tag ships. A push to main runs make check and deploys nothing:

# .github/workflows/ci.yml
on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify
        run: make check

Pushing a v* tag runs the same checks, copies only the files the site serves into _site with scripts/stage.py, stamps the tag into that copy, deploys it to Pages, and then cuts a GitHub release:

# .github/workflows/deploy.yml
on:
  push:
    tags: ['v*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify
        run: make check
      - name: Stage and stamp
        run: python3 scripts/stage.py "$GITHUB_REF_NAME" _site
      - uses: actions/upload-pages-artifact@v3
        with:
          path: _site
      - uses: actions/deploy-pages@v4
  release:
    needs: deploy
    if: startsWith(github.ref, 'refs/tags/')
    steps:
      - run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag
flowchart LR
  main["git push origin main"] --> ci["make check"]
  ci --> stop["deploy nothing"]
  tag["git push origin v1.6.1"] --> check["make check"]
  check --> stage["stage.py stamps _site"]
  stage --> pages["GitHub Pages"]
  pages --> rel["gh release create"]

The tag is the only place a version is written. An unstamped working copy prints dev in the top bar; the live origin prints v1.6.1.

stage.py is the boundary between the repo and the origin. The repo holds the Makefile, the checkers, CLAUDE.md, and the screenshots in assets/readme/. None of that is on fewbottles.com. SERVED is the one list of what the origin hands out. Uploading the repo used to put the working notes on the origin, crawlable under the site’s own name. The staging step is what stopped that.

SERVED = [
    "index.html", "404.html", "offline.html", "sw.js",
    "manifest.webmanifest", "CNAME", ".nojekyll", "robots.txt",
    "sitemap.xml", "humans.txt", "llms.txt", "llms-full.txt",
    "assets", "data", "drink",
]
NOT_SERVED = ("assets/readme/",)

The stamp lands in three places, each exactly once, or the deploy refuses the upload: __BUILD__ in sw.js becomes the cache name, __VERSION__ in assets/app.js becomes the label on the Info tab, and ?v=v1.6.1 goes on the stylesheet and script tags.

def stamps(version, names):
    out = [
        ("sw.js", "__BUILD__", version),
        ("assets/app.js", "__VERSION__", version),
        ("index.html", 'href="assets/app.css"',
         f'href="assets/app.css?v={version}"'),
        ("index.html", 'src="assets/app.js"',
         f'src="assets/app.js?v={version}"'),
    ]
    pages = ["404.html", "offline.html"]
    pages += sorted(n for n in names if PAGE.match(n))
    out += [(p, 'href="/assets/app.css"',
             f'href="/assets/app.css?v={version}"')
            for p in pages]
    return out

GitHub Pages holds JavaScript for four hours and HTML for ten minutes. A visitor with no service worker, a private window, a drink page opened cold, would otherwise get the new page against the last release’s script.

After a tag, the origin is GitHub Pages. curl -sI https://fewbottles.com/ still says so: x-github-request-id, x-github-edge-region, Fastly’s via: 1.1 varnish, cache-control: max-age=600. Cloudflare is in front of that, server: cloudflare, HTTP/3 in alt-svc. The files came from GitHub. The visitor never has to know.

§Cloudflare is the front door

GitHub Pages will serve username.github.io. It will not, by itself, put a CNAME on an apex domain. DNS does not allow a CNAME at fewbottles.com without a trick, and the trick Cloudflare popularized is CNAME flattening: you write a CNAME at @ pointing at cjimti.github.io, Cloudflare resolves it to addresses, and visitors see A records for Cloudflare’s anycast IPs. Flattening at the apex is on by default, including on the Free plan. That is the entire reason the nameservers for this domain are Cloudflare’s rather than the registrar’s.

The orange cloud, proxy on, is the rest of the job. Visitors hit Cloudflare over HTTPS. Cloudflare fetches GitHub Pages. GitHub’s own HTTPS enforcement on the custom domain is off, which is the usual arrangement when Pages never sees the visitor: HTTP-01 for a GitHub-issued certificate cannot complete against a domain Cloudflare is answering. Cloudflare holds the visitor-facing certificate. http://fewbottles.com/ 301s to https://fewbottles.com/. www 301s to the bare host.

flowchart LR
  HTTP["http://fewbottles.com"] -->|301| HTTPS["https://fewbottles.com"]
  WWW["https://www.fewbottles.com"] -->|301| HTTPS
  HTTPS --> CF["Cloudflare<br/>TLS, anycast"]
  CF --> GH["GitHub Pages<br/>Fastly, max-age=600"]
sequenceDiagram
  participant Phone
  participant CF as Cloudflare
  participant GH as GitHub Pages
  Phone->>CF: GET https://fewbottles.com/
  CF->>GH: origin fetch
  GH-->>CF: 200, cache-control max-age=600
  CF-->>Phone: server cloudflare, HTTP/3
  Phone->>Phone: localStorage drink.bar.v1

Cloudflare is also where response headers would live, because GitHub Pages does not let you set them on the origin. HSTS, a Content-Security-Policy that names every inline script by hash, X-Content-Type-Options, Referrer-Policy, Permissions-Policy: those are Transform Rules, not files. scripts/probe.py reads the live origin for exactly that reason. make verify cannot see a Cloudflare setting, and CI has no business passing or failing on the state of the edge. make probe is the command, and it is read-only.

Cloudflare Web Analytics counts page views and load times with a script Cloudflare injects on the way out. It sets no cookie. Google Analytics, through Tag Manager, counts the in-app events: which tab opened, which drink expanded, which bottle was ticked, whether a menu was printed or shared. Both are disclosed on the Info tab. Neither tells me who you are. The shelf never leaves the phone unless you share it yourself.

§Why a server would be a waste

The usual reason to pay for hosting is that something has to run. A request comes in, a process looks something up, a row comes back, HTML goes out. That is a real architecture, and it is the wrong architecture for this menu.

flowchart TB
  subgraph paid["The architecture I did not pick"]
    R1[request] --> APP["app server"]
    APP --> DB[(database)]
    APP --> AUTH["accounts / sessions"]
    APP --> UUID["share UUID lookup"]
  end
  subgraph hobby["fewbottles.com"]
    R2[request] --> FILES["GitHub Pages files"]
    FILES --> JSON["data/*.json"]
    PHONE[phone] --> LS[("localStorage")]
    PHONE --> BIT["?s= bitmask"]
  end

There is no user. There is no row. The catalog is the same for everyone: 185 drinks, 50 ingredients, three methods. It is public JSON. What varies is which bottles you own, and that is not my data. It is yours, it sits in localStorage under drink.bar.v1, and a private window that cannot write storage still shows the menu, just with an empty shelf.

The alternatives I did not pick are the ones that would have invented a server. An account so the shelf syncs across devices. A database so I can “save your bar.” A backend so a share link is a UUID I look up. Each of those is a hosting bill, a privacy policy, and a recovery flow for a forgotten password on a cocktail menu. The share link is a number. The shelf is bits. The phone already knows how to store both.

§Four JSON files

No build step, no framework, no package.json. The app renders from four JSON files: three written by hand, and kin.json generated from them.

flowchart TB
  cocktails["data/cocktails.json<br/>code plus build"]
  bar["data/bar.json<br/>bottles and bits"]
  notation["data/notation.json<br/>Barline table"]
  kin["data/kin.json<br/>generated families"]
  check["check_menu.py"]
  kinpy["kin.py"]
  app["assets/app.js"]
  cocktails --> check
  bar --> check
  notation --> check
  cocktails --> kinpy
  kinpy --> kin
  cocktails --> app
  bar --> app
  notation --> app
  kin --> app
  check -->|"code must equal rebuild"| cocktails

data/cocktails.json is the menu. Each drink carries both a code and a build. That duplication is the point. code is transcribed from the paper card and is the thing being preserved. build is the same drink spelled out for the app. scripts/check_menu.py regenerates the code from the build and refuses any drink where the two disagree. That check is the only reason a hundred-odd hand-typed shorthand strings can be trusted. A Kingston Negroni is:

{ "id": "kingston-negroni", "name": "Kingston Negroni", "method": "stirred",
  "family": "aged-rum", "code": "1,1,1,Ro", "serve": "Ro",
  "build": [["aged-rum","1"],["campari","1"],["sweet-vermouth","1"]] }

The checker walks the build, skips amounts flagged as garnish, joins what is left with the serve token, and compares:

parts = []
for entry in d["build"]:
    ing, amt = entry[0], entry[1]
    flag = entry[2] if len(entry) > 2 else None
    if amt is None:
        continue
    if flag != "g":
        parts.append(amt)

rebuilt = ",".join(parts + [serve])
if rebuilt != d["code"]:
    errs.append(f"{who}: code {d['code']!r} but build spells {rebuilt!r}")

Ids are stable keys. They key the expanded-recipe state and any link anyone has sent. They are never renamed or reused.

data/bar.json is every bottle any drink can call for, garnish included. Each ingredient has a bit, and a bit is a stable key the same way an id is. A new bottle takes the next unused bit, wherever it sits in the file. Dropping one means moving its bit into retired_bits. The checker refuses a duplicate or a retired bit.

{ "bit": 0, "id": "gin", "name": "Gin", "short": "gin", "kind": "base",
  "bottles": [
    { "id": "beefeater", "name": "Beefeater London Dry Gin",
      "size": "1.75L", "price": 35, "tier": "solid" },
    { "id": "tanqueray", "name": "Tanqueray London Dry Gin",
      "size": "1.75L", "price": 25, "tier": "solid" }
  ]
}

kind groups the shelf: base, vermouth, modifier, bitters, syrup, juice, mixer, garnish. unit: "dash" is what tells the decoder a bare number counts dashes. Garnish is stocked like anything else, but it never gates a drink. A Martini with no olive is still a Martini.

data/notation.json is the Key tab, and the table the decoder reads from. Adding a garnish letter here is what makes it decodable. There is no second list in the JavaScript. The same table that makes a letter readable makes it countable.

[
  { "code": "q", "label": "1/4 oz", "oz": 0.25 },
  { "code": "Q", "label": "3/4 oz", "oz": 0.75 }
]

data/kin.json is generated. scripts/kin.py reads every build, files the drink under a named shape (Martini, Sour, Daisy, Negroni, Old-Fashioned), and lists the nearest others of that shape with the bottle that changed. The Martini and the Manhattan sit under gin and bourbon on the printed card, and in the same pattern here. The app does not recompute this. make verify refuses a drift the same way it refuses a code that does not match its build.

Two more generated dumps ride along for agents: llms.txt is the map, one line per drink, and llms-full.txt is the menu spelled out so a model does not have to run the decoder. They are not edited by hand.

The shorthand itself is contextual, which is why a static file can replace a recipe database. A bare 2 is two ounces of rye and two dashes of Angostura. q is a quarter ounce and Q is three quarters; case is load-bearing, so a search for Q leaves the quarters out. The last token is glass plus garnish, matched longest-first: ccin is a coupe with grated cinnamon, not c + i + n. h is a half ounce everywhere except the last slot, where it is the tall glass. The decoder in assets/app.js reads each amount against the ingredient it belongs to. That is what turns a margin note into a recipe without anyone typing the recipe twice.

§The shelf is a bitmask

Tick what you own on the Bar tab and the ticks write a plain object to localStorage:

var STORE = 'drink.bar.v1';
var BRAND_STORE = 'drink.brands.v1';
var TITLE_STORE = 'drink.menuTitle.v1';
var PRINT_STORE = 'drink.print.v1';
var INTRO_STORE = 'drink.intro.v1';
var SHARE_PARAM = 's';  /* fewbottles.com/?s=<shelf code> */
function loadHave() {
  have = {};
  try {
    var v = JSON.parse(localStorage.getItem(STORE));
    if (!plainObject(v)) return;
    Object.keys(v).forEach(function (k) { if (ing[k]) have[k] = v[k]; });
  } catch (e) { /* unreadable, so the shelf starts empty */ }
}

function saveHave() {
  try { localStorage.setItem(STORE, JSON.stringify(have)); }
  catch (e) { /* private mode */ }
}

Private mode that throws on setItem is caught and ignored. A store holding true or "gin" from a hand edit is dropped on the way in, so a tick never writes a property onto a boolean. A bottle id the bar no longer lists is dropped too, or boot would write it back forever.

A menu leaves the phone as one number: fewbottles.com/?s=281474976710655. Every ingredient in bar.json carries a bit. Bit N set means that type is stocked. Brands are never in the code. A guest needs to know there is gin, not which gin. The number is a decimal string, because a number is the thing a person can read back over the phone, and it is read with BigInt, never Number. A double is exact to 53 bits. The fifty-fourth bottle would round the whole shelf.

function shelfCode(held) {
  if (typeof BigInt !== 'function') return '';
  var n = BigInt(0);
  data.bar.ingredients.forEach(function (i) {
    if (held[i.id] && typeof i.bit === 'number')
      n = n | (BigInt(1) << BigInt(i.bit));
  });
  return n.toString();
}

function shelfFromCode(code) {
  if (typeof BigInt !== 'function' || !/^\d{1,400}$/.test(code || ''))
    return null;
  var n = BigInt(code);
  var out = {}, any = false;
  data.bar.ingredients.forEach(function (i) {
    if (typeof i.bit !== 'number') return;
    if ((n >> BigInt(i.bit)) & BigInt(1)) { out[i.id] = true; any = true; }
  });
  return any ? out : null;
}
sequenceDiagram
  participant Host as "Host phone"
  participant Store as "localStorage"
  participant Url as "share URL"
  participant Guest as "Guest phone"
  Host->>Store: ticks write drink.bar.v1
  Host->>Host: shelfCode BigInt bitmask
  Host->>Url: share the decimal
  Guest->>Url: opens the URL
  Guest->>Guest: shelfFromCode into session memory
  Note over Guest,Store: guest storage untouched until Make this my shelf

Opening a shared link is reading, not adopting. The sender’s shelf is held in memory for the session. The guest’s own localStorage is not touched until they tap Make this my shelf. That is the whole sync story, and it does not have a server.

The Bar tab’s number beside each unopened bottle is drinks unlocked, not drinks mentioned. marginalGain adds that bottle to the shelf and re-counts. A bottle used in twelve drinks that unlocks none reads in 12, greyed, and that is the honest answer.

function missingFor(d, held) {
  var out = [];
  pours(d).forEach(function (id) {
    if (held[id] || standInHeld(id, held)) return;
    if (out.indexOf(id) < 0) out.push(id);
  });
  return out;
}

function canPour(d, held) { return missingFor(d, held).length === 0; }

function pourableCount(held) {
  return data.menu.cocktails.filter(function (d) {
    return canPour(d, held);
  }).length;
}

function marginalGain(id, held) {
  if (held[id]) return 0;
  return pourableCount(withBottles(held, [id])) - pourableCount(held);
}

From gin, bourbon, both vermouths, two bitters, and the staples you can pour 11 drinks. The best next bottle is not a spirit. It is orange liqueur, at +8, then Bénédictine at +6 and maraschino at +5.

The Bar tab on a phone: 11 drinks from 14 bottles, and One more bottle listing orange liqueur at +8, Bénédictine at +6, maraschino at +5.

When singles run out, the empty rows go to the smallest sets that still open something. A set is selected in one tap like a bottle. The heading says One more bottle while every row is one bottle, and What to buy next once a row names a pair, because a heading that says one bottle over a pair is a lie about what it is asking you to buy.

The Menu on a laptop with My Shelf selected: 11 drinks on the left, One more bottle on the right.

§What the paper menu cannot do

A hash never leaves the phone. iMessage, Messages, X, Slack, and Googlebot all fetch the address before the # and read the meta tags they find there, with no JavaScript run. So fewbottles.com/drink/martini/ is a page.

flowchart LR
  subgraph hash["A hash stays on the phone"]
    H["#drink/martini"] --> JS["needs JavaScript"]
  end
  subgraph page["A page unfurls"]
    P["/drink/martini/"] --> HTML["recipe in the HTML"]
    P --> OG["og:image card"]
    P --> LD["JSON-LD Recipe"]
  end

scripts/pages.py writes drink/<id>/index.html for every drink: the recipe in the HTML, a Recipe block of JSON-LD, the card in the og and twitter tags. scripts/cards.py draws assets/cards/<id>.png, 1200 by 630, name and ingredient lines and the glass art the serve token calls for. The page does not redirect to the app, because a redirect is what makes a crawler index the front page instead. The old #drink/<id> form still opens the drink inside the app and always will. The page is what the Share tab hands out now.

{
  "@type": "Recipe",
  "@id": "https://fewbottles.com/drink/martini/#recipe",
  "name": "Martini",
  "url": "https://fewbottles.com/drink/martini/",
  "image": "https://fewbottles.com/assets/cards/martini.png",
  "recipeIngredient": [
    "2 oz Gin",
    "1 oz Dry vermouth",
    "1 dash Orange bitters"
  ],
  "recipeInstructions": [
    { "@type": "HowToStep",
      "text": "Stir with ice until cold, then strain." },
    { "@type": "HowToStep",
      "text": "Serve: coupe, lemon twist." }
  ]
}

Print is a reveal on the list you are looking at. Name the menu, keep the glass icon or drop it, tick recipe, taste, or history if you want them on paper, add a Barline sheet if a guest needs the key. Two columns on US Letter, a QR back to the site. The print stylesheet forces the light palette outright. What theme a phone happens to be in must never decide how much toner a menu costs.

WebKit has never honoured CSS multicolumn on paper (bug 15546, open since 2007). Every browser on iOS, and Safari on a Mac, would print one long column. So on WebKit the app renders the list already cut in two as floated halves. Chrome and Firefox keep real columns, which balance per sheet. A float pair reads down the whole left half and then the whole right, so on a two-sheet menu the order is wrong; multicol gets it right where it works. That workaround exists because the printed card is the thing this project preserves.

A printed menu page in black and white: two columns of drinks, shorthand in the margin.

Add it to the home screen and it keeps working with no signal. sw.js caches the shell, cache-first, and the four JSON files network-first with a cached fallback. A phone propped against the back bar has no business needing signal to show a recipe.

function fresh(path) {
  return path + (path.indexOf('?') < 0 ? '?' : '&') + 'v=' + VERSION;
}

self.addEventListener('install', function (e) {
  e.waitUntil(
    caches.open(CACHE).then(function (c) {
      return Promise.all(SHELL.map(function (path) {
        return fetch(fresh(path), { cache: 'reload' }).then(function (res) {
          if (!res.ok) throw new Error(path);
          return c.put(path, res);
        });
      }));
    }).then(function () { return self.skipWaiting(); })
  );
});
flowchart TB
  NAV[navigation] --> SHELL{"in SHELL?"}
  SHELL -->|yes| CF["cache-first"]
  SHELL -->|"data/*.json"| NF["network-first"]
  NF -->|ok| STORE["store on path"]
  NF -->|offline| FALL["cached copy"]
  CF --> EDGE["fetch path?v=tag<br/>misses the ten-minute edge"]

The worker registers in production only. Off https, the app actively unregisters any worker it finds, because a worker owns an origin: every static site in this workspace serves ./, index.html, and assets/app.js, so one left on localhost:8000 will answer for the next project that runs there, cache-first, with no server needed. This repo serves on port 8010 so the origins never overlap.

Cloudflare and GitHub’s edge both hold a file for ten minutes after a deploy, and cache: 'reload' skips only the browser’s copy. The worker fetches its shell with ?v=<version> on the end, which no edge has seen. The four data fetches carry the same query, so a new script never reads the last release’s menu off the edge. The worker stores and matches them on the path alone, and copies them into the new cache before deleting the old one, so the first open after a release with no signal still has a menu.

make verify is the whole pipeline, because there is no build step for a pipeline to hide behind.

.DEFAULT_GOAL := verify

check: json syntax lint test unit menu assets
	@echo "all checks passed"

unit:
	@node --test --test-reporter=./scripts/unit-report.mjs scripts/app.test.mjs

menu:
	@python3 scripts/check_menu.py
	@python3 scripts/kin.py --check
	@python3 scripts/llms.py --check
	@python3 scripts/pages.py --check
	@python3 scripts/cards.py --check
flowchart LR
  json[json] --> syntax[syntax]
  syntax --> lint[lint]
  lint --> test["test_checks.py"]
  test --> unit["app.test.mjs"]
  unit --> menu["codes match builds"]
  menu --> assets["every href exists"]

It parses every JSON file, syntax-checks the scripts, and checks that every shorthand code still agrees with the recipe it stands for and every generated file still matches the menu. scripts/check_code.py is the linter this repo has instead of eslint: function size, complexity, nesting, and the foot-guns a static site cannot afford, eval, a console.log shipped to somebody’s phone, a radix-less parseInt. Five functions are already over the line and live in a BUDGET with the reason. A budget entry is a ceiling, not a pass. It holds a function at exactly today’s size, so it can shrink and never grow.

§What now exists

fewbottles.com is a static origin on GitHub Pages, reached through Cloudflare. The catalog is data/cocktails.json, data/bar.json, data/notation.json, and generated data/kin.json. The shelf is localStorage key drink.bar.v1. A shared menu is ?s= plus a BigInt. A shared drink is /drink/<id>/, a real page, because a hash does not unfurl. Offline is a service worker that keys its cache on the tag that shipped it.

I do not know why you want to host a cocktail menu. I have my reasons for doing so. You do not need Kubernetes to run a website, and you do not need a VPS, a platform-as-a-service, or a database either. You need files, a place that will serve them, and the discipline to keep the state on the device that owns it. GitHub Pages will serve the files. Cloudflare will sit in front of them. The hosting bill is zero, and the domain is the only line on the invoice that was ever going to be.

← back to all notes