---
title: Hosting a site on dev.jeffpelton.com
description: 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`](/_site/devsite.cjs), and a GitLab CI component. This page is also served as
raw markdown at [`/hosting.md`](/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](#devsite-json)) 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](#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](#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](#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`:

   ```markdown
   ---
   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.

```json
{
  "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](#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:

  ```html
  <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:

  ```sh
  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:

  ```js
  // 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:

```yaml
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](#choosing-a-mode)).
- A missing file returns the shared [404 page](/404.html) 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

```sh
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`](/_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.
