← jeffpelton.com dev.jeffpelton.com

Hosting a site on dev.jeffpelton.com

How any project publishes a static site to dev.jeffpelton.com, at a path or its own subdomain, once or on every merge.

Any project that builds to a folder of static files (HTML, JS, CSS, images, WASM and so on) can live at https://dev.jeffpelton.com/<name>/, or at its own subdomain, https://<name>.dev.jeffpelton.com/. It works much like Vercel or Netlify for static sites: publish once with one command, and optionally let GitLab CI republish on every merge to the default branch. There's no server to run, and the site is listed on the index page unless it opts out.

Under the hood it's an S3 bucket behind CloudFront (HTTPS, HTTP/2 and 3, gzip/brotli), with a small dependency-free CLI, devsite.cjs, and a GitLab CI component. This page is also served as raw markdown at /hosting.md.

Playbook: "publish this to dev.jeffpelton.com"

This is for an agent (or a person) working in some project who has been asked to publish, deploy or host it on dev.jeffpelton.com (or "to ~/dev.jeffpelton.com"). Run everything from the project's root. devsite below means:

  • on Jeff's machine: node ~/dev.jeffpelton.com/bin/devsite.cjs
  • anywhere else: curl -fsSL https://dev.jeffpelton.com/_site/devsite.cjs | node -

Steps:

  1. Configure. Run devsite init. This writes devsite.json (see below) with a name guessed from package.json or the folder name, the build folder, and a title. Adjust the name and description, and run devsite list to make sure the name isn't taken by another project.

  2. Pick how it's served (see Choosing a mode): the default path mode, or "subdomain": true, plus "spa": true if it uses client-side routing. Then set the build's base path to match: /<name>/ for path mode, / for a subdomain (see Build for a subpath).

  3. Publish once. Build, then run devsite publish. If it warns about root-relative URLs outside /<name>/, fix the base path and publish again.

  4. Verify. Open the URL that publish printed in a browser and check that the page loads with no failed requests. If no browser is available, curl the page and a couple of its assets.

  5. Fill in the index listing. Every published site is listed on the index page by default. That's how Jeff keeps track of everything published here, so treat the listing as part of the job. In devsite.json:

    • title: the project's real name, not the repo slug.
    • description: one sentence saying what it is and how to use or play it.
    • tags: 1–4 short lowercase tags (game, tool, three.js, ai, …).
    • links: optional extras (a source repo if it's public, a related site).
    • Keep unlisted false unless Jeff asked to hide the site (for example unfinished or private work). devsite list still shows unlisted sites to Jeff.
    • Preview: each index entry is a card showing the site's share card (its og:image) and a Demo button when there's a demo video. Jeff wants both whenever possible, so the experiment is captured as fully as it can be. See Previews:
      • Give the site a 1200×630 share card: og:image (absolute URL) in its index.html, plus a real <title>, description, og:title and og:description. publish picks the card up automatically.
      • Ask Jeff whether he has a demo video (a file, or a YouTube/Vimeo link). If not, offer to record one. Set it as "video" in devsite.json.

    Publish again after editing; the listing updates immediately. publish prints which preview it found.

  6. Auto-publish on merge. Do this when the project's origin is on git.wefav.com (git remote -v). Run devsite ci-setup. It:

    • adds the publish component to .gitlab-ci.yml: it creates the file, or appends to one that has no include: list, or prints the entry to add to an existing include: list;
    • picks the build: command from the lockfile. Check it, and override with --build "<cmd>" if needed;
    • sets the publish key as masked, protected CI variables on the project.

    Commit devsite.json and .gitlab-ci.yml. From then on, every push or merge to the default branch republishes the site.

    • Not on GitLab yet? Skip this step and tell Jeff. Once the project is pushed, devsite ci-setup finishes it. Don't create GitLab projects unless asked.
    • The first push to the default branch deploys. Follow the project's normal branch/MR habits, and don't push to master just to try it out unless Jeff says so.
  7. Suggest a write-up (optional). Offer Jeff a short note about the project on the index site. If he wants one, add ~/dev.jeffpelton.com/content/notes/YYYY-MM-DD-<name>.md:

    ---
    title: Voxel Golf
    description: Mini golf on a voxel island, built in a weekend with three.js.
    site: voxel-golf
    ---
    
    One to three short paragraphs: what it is, how it was made, what's interesting or next.
    

    site: links the note and the site both ways (an "Open …" link on the note, a "write-up" link on the site's listing). Commit only that file in jeff/dev.jeffpelton.com and push to main; its CI redeploys the index site in about a minute. Don't change anything else in that repo.

  8. Report back: the URL, whether it's listed, which previews it has (share card, demo video), whether auto-publish is set up, whether there's a write-up, and anything left for Jeff to do.

Don't touch the bucket, the CloudFront distribution, the key-value store or the IAM user directly. Those are managed in ~/aws-jeffshouse.

Choosing a mode

Two flags in devsite.json decide how a site is served. Both default to false, and both can be changed later by editing devsite.json and publishing again.

Situation Settings URL Base path
Most sites: games, toys, multi-page static sites (defaults) dev.jeffpelton.com/<name>/ /<name>/
Client-side routing (React Router, Vue Router, …) "spa": true dev.jeffpelton.com/<name>/ /<name>/, and the router's basename /<name>
Needs to be served from /, or needs its own origin "subdomain": true <name>.dev.jeffpelton.com/ /
Both "subdomain": true, "spa": true <name>.dev.jeffpelton.com/ /

Choose subdomain when the project:

  • has a service worker or PWA manifest scoped to /,
  • hard-codes root-absolute paths it can't easily change,
  • registers OAuth or other redirect URLs,
  • needs storage and cookies that no other experiment can read or overwrite (anything with a login or data worth keeping),
  • or should simply have a short URL to share.

It costs nothing extra: no per-site DNS, certificate or setup.

Choose spa when the app routes in the browser with real paths (/level/2, not /#/level/2). Every URL whose last segment has no file extension then serves the site's index.html, and the router takes it from there. Real files (anything with an extension) are still served as usual. In spa mode, extensionless URLs never reach other HTML files, so link to extra pages by file name (docs/index.html) if you have any. Without spa, a URL like /level/2 redirects to /level/2/ and 404s unless that folder exists.

When a site switches to subdomain, its old dev.jeffpelton.com/<name>/… URLs permanently redirect to the subdomain. A subdomain for a site that isn't in subdomain mode redirects to the site's path URL. After changing either flag, give it about 30 seconds before testing, because CloudFront's edges pick up the new routing within that time.

devsite.json

This file sits in the project root and is committed with the project. It's the one source of truth for both local and CI publishes.

{
  "name": "voxel-golf",
  "dir": "dist",
  "title": "Voxel Golf",
  "description": "Mini golf on a voxel island. Drag to aim.",
  "tags": ["game", "three.js"],
  "unlisted": false,
  "video": "https://youtu.be/dQw4w9WgXcQ",
  "subdomain": false,
  "spa": false,
  "immutable": ["assets/"],
  "links": [{ "label": "source", "url": "https://git.wefav.com/jeff/voxel-golf" }]
}
Field Meaning
name Gives https://dev.jeffpelton.com/<name>/ (or https://<name>.dev.jeffpelton.com/). Lowercase letters, digits and dashes, 1–63 characters. Required.
dir Folder to upload. Default dist. For plain HTML with no build step, use the folder holding index.html, or . for the project root. .git, node_modules, .env*, .gitlab-ci.yml and devsite.json are never uploaded.
title, description, tags What the index page shows.
unlisted true keeps the site live at its URL but off the index page, the sitemap and sites.json.
image Index card image. Default: the og:image in the site's index.html, so usually leave it out. An absolute URL, or a path inside the site.
video Demo video: a YouTube or Vimeo URL, a video file in the site (demo.mp4), a file uploaded with devsite media (_media/demo.mp4), or any other URL (opened in a new tab).
subdomain true serves the site at https://<name>.dev.jeffpelton.com/ from its own root. See Choosing a mode.
spa true serves index.html for every extensionless path, for client-side routers.
immutable Folders whose files have content hashes in their names (Vite assets/, Astro _astro/). Browsers cache these for a year. Leave it out if unsure.
links Extra links shown on the index entry (label + absolute url).

Every field can be overridden on the command line: --name, --dir, --title, --description, --tags a,b, --unlisted / --listed, --subdomain / --no-subdomain, --spa / --no-spa, --image <url|path>, --video <url|path>.

Previews

The index shows every site as a card: its share card image on top, and a Demo button when it has a video. Video files also play silently when you hover over the card. The button opens the video in a lightbox; YouTube and Vimeo play embedded. A write-up note with site: <name> shows the video (or the card) at the top. Sites with neither get a generated placeholder, so it's worth adding both.

Share card

  • A 1200×630 PNG or JPG in the site's root, for example public/og.png in a Vite project, so it ships as og.png.

  • Referenced from index.html with an absolute URL, the one social sites need:

    <meta property="og:title" content="Voxel Golf">
    <meta property="og:description" content="Mini golf on a voxel island. Drag to aim.">
    <meta property="og:image" content="https://dev.jeffpelton.com/voxel-golf/og.png">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta name="twitter:card" content="summary_large_image">
    

    For a subdomain site the image URL is https://<name>.dev.jeffpelton.com/og.png.

  • Make it look good: the game's title in big type over a real screenshot or key art works well. One way is to build the card as an HTML page, then screenshot it at exact size: npx -y playwright@1 screenshot --viewport-size "1200, 630" --wait-for-timeout 1500 file://$PWD/og-card.html public/og.png (add --channel chrome to use an installed Chrome instead of downloading one).

  • publish warns when the card is missing, relative, or points at a file that isn't in the build.

Demo video

  • YouTube / Vimeo: "video": "https://youtu.be/<id>" (or a youtube.com/watch, shorts or vimeo.com URL). Nothing to upload. If there's no share card, the YouTube thumbnail is used instead.

  • A video file: MP4 (H.264) plays everywhere. Keep it short (15–90 s) and ideally under 30 MB, and it can be silent. Encode it with:

    ffmpeg -i input.mov -vf "scale='min(1920,iw)':-2" -c:v libx264 -crf 26 -preset slow -pix_fmt yuv420p \
      -movflags +faststart -an demo.mp4
    

    Then either:

    • put it in the build (public/demo.mp4) and set "video": "demo.mp4" (fine for small files), or
    • keep it out of git: devsite media demo.mp4 uploads it once to /<name>/_media/demo.mp4, then set "video": "_media/demo.mp4". Publishes (including CI) never delete _media/, so upload it again only when the video changes.
  • No video yet? Offer to record one. Playwright can drive the site and record the session:

    // record.mjs — npx -y playwright@1 install chromium, then: node record.mjs
    import { chromium } from 'playwright';
    const browser = await chromium.launch();
    const context = await browser.newContext({ viewport: { width: 1280, height: 720 },
      recordVideo: { dir: 'recordings', size: { width: 1280, height: 720 } } });
    const page = await context.newPage();
    await page.goto('https://dev.jeffpelton.com/<name>/');
    // ...play: clicks, key presses, waits that show the best 20-40 seconds...
    await context.close();   // writes recordings/<id>.webm
    await browser.close();
    

    Convert the .webm to demo.mp4 with the ffmpeg command above. WebGL/three.js sites may need chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }) when there's no GPU. Show Jeff the result before using it.

Build for a subpath

(Path mode only. Subdomain sites use the default base /, and publish warns if their HTML still points into /<name>/.)

In path mode the site is served from /<name>/, not /, so its build has to know that. Otherwise /assets/app.js points at the site root and 404s. publish warns when it finds root-relative URLs outside /<name>/.

Tool Setting
Vite (React, Vue, Svelte, vanilla) base: '/<name>/' in vite.config
Astro base: '/<name>' in astro.config (and use import.meta.env.BASE_URL for links)
Next.js output: 'export', basePath: '/<name>', then dir: "out"
SvelteKit adapter-static, paths: { base: '/<name>' }
Plain HTML relative URLs (src="app.js", not src="/app.js")
React Router / Vue Router (with spa) basename /<name> (createBrowserRouter(routes, { basename: import.meta.env.BASE_URL }))

Auto-publish from GitLab CI

The whole CI setup for a project is this include. devsite ci-setup writes it for you:

include:
  - component: git.wefav.com/jeff/dev.jeffpelton.com/publish@main
    inputs:
      build: "npm ci && npm run build"

It adds one job, devsite-publish. The job runs only on the default branch, so a merge to master (or main) publishes. It installs the AWS CLI if the image lacks it, runs build, downloads the current devsite.cjs, and runs publish, which reads devsite.json as usual. Publishes run one at a time per project (resource_group).

Input Default
build npm ci && npm run build Any shell command. Use "true" for sites with no build step.
image node:22-alpine Needs Node 18+. Alpine and Debian-based images both work.
stage deploy Set it if the project's stages: list has no deploy.

Requirements:

  • The key: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY CI variables, set by devsite ci-setup (or devsite ci-setup --vars-only to only set them). This needs Jeff's machine: the key is copied from its local dev-jeffpelton AWS profile using glab.
  • A protected default branch: the variables are protected, so only protected branches see them. GitLab protects the default branch automatically, and MR branches never get the key.
  • git.wefav.com: the component lives in the jeff/dev.jeffpelton.com project there.

Routing rules

  • /<name>/ serves /<name>/index.html, and /<name>/about/ serves /<name>/about/index.html. On a subdomain it's the same without the /<name> prefix.
  • Without spa, /<name> (no trailing slash) permanently redirects to /<name>/, and so does any path whose last segment has no file extension. Every file needs an extension.
  • With spa, every extensionless path serves the site's index.html (see Choosing a mode).
  • A missing file returns the shared 404 page with a real 404 status.
  • There's no server-side code, redirects or custom headers. Anything dynamic has to happen in the browser or call an API somewhere else.

Caching

  • Every file is served with max-age=0, must-revalidate to browsers (so a reload always sees the latest publish) and cached at the CDN until the next publish invalidates /<name>/*.
  • Folders listed in immutable get max-age=31536000, immutable. Only list folders whose file names change when their content changes.
  • publish waits for the CDN to clear its cached copies, which usually takes under a minute. When it prints live: the new version is being served. --no-wait skips the wait.

All commands

devsite init                        # write devsite.json
devsite publish [--dry-run]         # upload dir -> /<name>/ (--dry-run: list changes, touch nothing)
devsite ci-setup [group/project]    # auto-publish on merge: CI include + key (--build, --vars-only, --ci-only)
devsite media <file>                # upload a demo video to /<name>/_media/ (kept across publishes)
devsite list                        # every site, including unlisted, with its source repo
devsite unpublish <name> --yes      # delete a site and drop it from the index
devsite doctor                      # check the AWS CLI + credentials + bucket access
devsite reindex [--previews]        # rebuild the index listing (--previews: re-read every site's og:image)

To vendor the CLI instead of piping it from curl: curl -fsSL https://dev.jeffpelton.com/_site/devsite.cjs -o scripts/devsite.cjs.

Credentials

Publishing uses the IAM user dev-jeffpelton-publisher. It can read and write objects in the dev.jeffpelton.com bucket and invalidate its CloudFront distribution, and nothing else. It's looked up in this order:

  1. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY in the environment (CI),
  2. AWS_PROFILE, if set,
  3. the local dev-jeffpelton profile in ~/.aws/credentials (Jeff's machine).

If none of these exist, devsite stops and says so. Only Jeff can hand out the key, so ask him rather than working around it.

Safety rails

  • Name ownership. Each publish records the git repo it came from (CI_PROJECT_URL in CI). Publishing the same name from a different repo is refused unless you pass --force, so two projects can't silently overwrite each other.
  • Undo. The bucket keeps old versions of every file for 30 days, so a bad publish or an unpublish can be rolled back.
  • Reserved names. Paths used by the index site itself (_site, notes, hosting, 404.html, ...) can't be used as site names.
  • Shared origin (path mode). Path-mode sites all share the origin dev.jeffpelton.com, so localStorage, IndexedDB and cookies are shared between them. Prefix storage keys with the site name (voxel-golf:settings). Use subdomain when a site needs its own origin. Either way, never put secrets in a site.

How the index finds sites

Every site's files live under <name>/ in the same S3 bucket, behind the same CloudFront distribution, whatever its mode. A CloudFront function maps each request onto that folder, and reads the subdomain/spa flags from a CloudFront key-value store that publish updates when they change. publish also writes a manifest to /<name>/devsite.json in the bucket (not publicly readable) and updates /_site/sites.json, the public list of listed sites. The index page is built from that list and re-fetches it in the browser, so a new site appears right away without rebuilding this site. devsite list shows every site, unlisted ones included, with its mode and source repo.

Write-ups are notes in the jeff/dev.jeffpelton.com repo (content/notes/YYYY-MM-DD-<slug>.md, frontmatter title, description, optional site: <name>). Pushing to main deploys the index site with them.