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:
Configure. Run
devsite init. This writesdevsite.json(see below) with anameguessed from package.json or the folder name, the build folder, and a title. Adjust the name and description, and rundevsite listto make sure the name isn't taken by another project.Pick how it's served (see Choosing a mode): the default path mode, or
"subdomain": true, plus"spa": trueif 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).Publish once. Build, then run
devsite publish. If it warns about root-relative URLs outside/<name>/, fix the base path and publish again.Verify. Open the URL that
publishprinted in a browser and check that the page loads with no failed requests. If no browser is available,curlthe page and a couple of its assets.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
unlistedfalse unless Jeff asked to hide the site (for example unfinished or private work).devsite liststill 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 itsindex.html, plus a real<title>,description,og:titleandog:description.publishpicks 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"indevsite.json.
- Give the site a 1200×630 share card:
Publish again after editing; the listing updates immediately.
publishprints which preview it found.Auto-publish on merge. Do this when the project's
originis on git.wefav.com (git remote -v). Rundevsite ci-setup. It:- adds the publish component to
.gitlab-ci.yml: it creates the file, or appends to one that has noinclude:list, or prints the entry to add to an existinginclude: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.jsonand.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-setupfinishes 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.
- adds the publish component to
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 injeff/dev.jeffpelton.comand push tomain; its CI redeploys the index site in about a minute. Don't change anything else in that repo.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.pngin a Vite project, so it ships asog.png.Referenced from
index.htmlwith 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 chrometo use an installed Chrome instead of downloading one).publishwarns 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 ayoutube.com/watch,shortsorvimeo.comURL). 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.mp4Then 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.mp4uploads 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.
- put it in the build (
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
.webmtodemo.mp4with the ffmpeg command above. WebGL/three.js sites may needchromium.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_KEYCI variables, set bydevsite ci-setup(ordevsite ci-setup --vars-onlyto only set them). This needs Jeff's machine: the key is copied from its localdev-jeffpeltonAWS profile usingglab. - 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.comproject 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'sindex.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-revalidateto browsers (so a reload always sees the latest publish) and cached at the CDN until the next publish invalidates/<name>/*. - Folders listed in
immutablegetmax-age=31536000, immutable. Only list folders whose file names change when their content changes. publishwaits for the CDN to clear its cached copies, which usually takes under a minute. When it printslive:the new version is being served.--no-waitskips 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:
AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEYin the environment (CI),AWS_PROFILE, if set,- the local
dev-jeffpeltonprofile 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_URLin 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
unpublishcan 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, solocalStorage, IndexedDB and cookies are shared between them. Prefix storage keys with the site name (voxel-golf:settings). Usesubdomainwhen 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.