~bigbes/sourcehut-root

ref: 72581ff94e21863373e2c72a01eb75332cb22f32 sourcehut-root/skills/sourcehut-ci/references/pages.md -rw-r--r-- 13.2 KiB
72581ff9 — Eugene Blikh docs(sourcehut-ci): fix factual errors found in full audit 2 days ago

#pages.sr.ht Deployment

pages.sr.ht hosts static websites. Each sourcehut user gets a subdomain at username.srht.site for free, plus unlimited custom domains. TLS is provisioned automatically.

#The core publish workflow

image: alpine/edge
packages:
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-site
tasks:
  - build: |
      cd my-site
      # whatever produces the static output
      tar -czf ../site.tar.gz -C public .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

Three things to get right:

  1. oauth: pages.sr.ht/PAGES:RW — gives the build a short-lived OAuth token with publish permission. The worker pre-writes ~/.config/hut/config with the token, so hut Just Works (it does not read $OAUTH2_TOKEN; the env var is also set, but it's for curl --oauth2-bearer). Do not use a personal access token as a secret for this — it's worse in every way.
  2. The tarball must contain only files at the top level, not a wrapping directory. tar -C <output-dir> . does this. If you tar public/ instead, you'll get a directory listing at /public/ on the site instead of an index page.
  3. hut pages publish -d <domain>-d takes the fully qualified domain, exactly as configured. Domains are case-sensitive in some edge cases; stick to lowercase.

#Tarball requirements

pages.sr.ht is strict about what it accepts:

  • Format: .tar.gz (gzipped tar). Plain .tar, .tar.bz2, .tar.zst, .zip are not accepted.
  • Only directories and regular files, mode 644. Symlinks are rejected.
  • No executables: file mode must be 644. Don't chmod +x anything before tarring.
  • Files at the top level: index.html at the tar's root, not nested in a directory.
  • Size limit: 1 GiB per site.
  • Invalid data is silently discarded (per the API docs). If your tarball has problems, pages.sr.ht may accept the publish call but serve nothing or stale content. When debugging, list the tarball: tar -tzf site.tar.gz | head.

Common tar invocation patterns:

# Site files are in ./public/ (Hugo, Gatsby default, Eleventy default)
tar -czf site.tar.gz -C public .

# Site files are in ./_site/ (Jekyll default)
tar -czf site.tar.gz -C _site .

# Site files are in ./out/ (Next.js export default, Astro default for static)
tar -czf site.tar.gz -C out .

# Site files are in ./book/ (mdBook)
tar -czf site.tar.gz -C book .

# Already at the project root (raw HTML, no generator)
tar -czf site.tar.gz .

#hut pages publish flags

hut pages publish -d <domain> [-s <subdirectory>] [--protocol https|gemini] [--site-config <path>] <tarball>
  • -d <domain> — required. Fully qualified domain.

  • -s <subdirectory> — optional. Publish to a sub-path. Files at the root of the tarball end up at https://domain/<subdirectory>/. The rest of the site is preserved (this lets you update part of a site without re-uploading everything). Maps to the GraphQL publish(subdirectory: ...) argument.

  • --protocol https|gemini — defaults to https. Set to gemini for Gemini-protocol publishing (the same pages.sr.ht GraphQL endpoint serves both; the Protocol enum has HTTPS and GEMINI).

  • --site-config <path> — optional. Path to a JSON file controlling per-site options. Schema (all fields optional):

    {
      "notFound": "404.html",
      "fileConfigs": [
        { "glob": "*.png", "options": { "cacheControl": "max-age=15552000" } },
        { "glob": "*.css", "options": { "cacheControl": "max-age=7200"     } }
      ]
    }
    

    notFound is the path (inside the tarball) of the file to serve for 404s. fileConfigs[].glob is matched against the request path; cacheControl sets the response header verbatim. Maps to the GraphQL publish(siteConfig: ...) argument.

#Custom domains

DNS setup depends on whether it's an apex domain (example.com) or a subdomain (www.example.com, docs.example.com):

#Apex domains — A + AAAA

CNAME on the apex is not legal under RFC 1034 (and many registrars reject it). pages.sr.ht's solution is fixed IPs you point an A/AAAA record at:

example.com.   IN A      46.23.81.157
example.com.   IN AAAA   2a03:6000:1813:1337::157

This is what the upstream srht.site docs publish. The IPs are stable but not guaranteed forever — sourcehut reserves the right to change them with 30 days' email notice to the affected accounts. Keep your meta.sr.ht email current.

If your self-hosted pages instance (e.g. on srht.bigb.es) uses different IPs, check your instance's pages.sr.ht/origin or the operator's docs — the IPs above are only correct for the upstream pages.sr.ht. service.

#Subdomains — CNAME

www.example.com.   IN CNAME pages.sr.ht.
docs.example.com.  IN CNAME pages.sr.ht.

The trailing period is significant (it makes the target absolute). Some lousy registrars choke on it — drop it if they do; most still infer correctness.

#Publishing

After DNS resolves, publish to the domain by name:

hut pages publish -d example.com site.tar.gz
  1. TLS is auto-provisioned on first request. The first load may take a few seconds while a cert is obtained.

Multi-subdomain publishing: example.com and www.example.com are separate sites. You must publish to both:

tasks:
  - publish-apex: |
      hut pages publish -d example.com site.tar.gz
  - publish-www: |
      hut pages publish -d www.example.com site.tar.gz

There's no redirect-from-www feature; if you only want one canonical hostname, publish to the canonical one and don't configure DNS for the other.

The srht.site recommendation is to skip www. entirely.

#Static site generator recipes

#Hugo

image: alpine/edge
packages:
  - hugo
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-site
tasks:
  - build: |
      cd my-site
      hugo --minify
      tar -czf ../site.tar.gz -C public .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

If your theme uses Dart Sass, Alpine's hugo package may not include the extended build. Either use the Hugo binary from upstream releases, or switch to archlinux where hugo is the extended build by default.

#Zola

image: alpine/edge
packages:
  - zola
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-site
tasks:
  - build: |
      cd my-site
      zola build
      tar -czf ../site.tar.gz -C public .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

#Jekyll

Jekyll wants Ruby; Alpine works but Ruby builds tend to be smaller/faster on Debian.

image: debian/stable
packages:
  - ruby
  - ruby-dev
  - build-essential
sources:
  - https://git.sr.ht/~username/my-site
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
tasks:
  - install-hut: |
      # hut isn't in Debian; build from source or download a release
      curl -L https://git.sr.ht/~xenrox/hut/refs/download/v0.6.0/hut-0.6.0-linux-amd64 -o /tmp/hut
      chmod +x /tmp/hut
      sudo mv /tmp/hut /usr/local/bin/hut
  - install-jekyll: |
      sudo gem install jekyll bundler
  - build: |
      cd my-site
      bundle install
      bundle exec jekyll build
      tar -czf ../site.tar.gz -C _site .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

Check the hut release URL — the version pin should be current; git.sr.ht/~xenrox/hut/refs lists tags.

#mdBook

image: alpine/edge
packages:
  - cargo
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-book
tasks:
  - install-mdbook: |
      cargo install mdbook
      echo 'export PATH=$HOME/.cargo/bin:$PATH' >> ~/.buildenv
  - build: |
      cd my-book
      mdbook build
      tar -czf ../site.tar.gz -C book .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

cargo install puts binaries in ~/.cargo/bin, which isn't on the default PATH in subsequent tasks. Appending to ~/.buildenv makes it available in the next task.

#Raw HTML / no generator

image: alpine/edge
packages:
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-site
tasks:
  - publish: |
      cd my-site
      tar -czf ../site.tar.gz .
      hut pages publish -d "$site" ../site.tar.gz

#Verifying the publish

After hut pages publish succeeds, the site is live. The CLI prints the new version hash on success. To verify what's actually served:

curl -sI https://username.srht.site | head -n 3

If you've just configured a new custom domain, the first HTTPS request will take a few extra seconds while a TLS cert is provisioned.

#Listing and removing sites

# List all your published sites
hut pages list

# Remove a site
hut pages unpublish -d example.com

#Site limits and rules

From the pages.sr.ht docs:

  • 1 GiB total storage per site, after decompression. Hard limit enforced at publish time.

  • Static content only — no server-side execution, no PHP, no CGI.

  • TLS only (HTTPS); plain HTTP redirects to HTTPS. Certs are issued automatically per-domain.

  • Content-Security-Policy is fixed for every published site. Among its effects:

    • Scripts and styles must be loaded from your own site, not a third-party CDN (script-src 'self' 'unsafe-eval' 'unsafe-inline', style-src 'self' 'unsafe-inline').
    • Inline <script> and <style> work (because of 'unsafe-inline' / 'unsafe-eval').
    • Tracking scripts pointing at GA/Plausible/etc. won't load (no third-party script-src).
    • Images, audio, and video may load over HTTPS from anywhere (img-src https:, media-src https:).
    • Embedded iframes must use HTTPS.
    • object/embed is blocked.

    If you need to host third-party JS for your site to work, you have to host it yourself (copy the file into your tarball and reference it locally).

  • Tarball contents: directories and regular files of mode 644 only. Symlinks are silently dropped. Invalid tarballs are silently discarded.

These are technical limits enforced by the publish endpoint and the CSP header, not just policies. Use a CDN if you need higher throughput or non-default CSP behavior.

#Common failure modes

"Published, but site shows old content" The tarball was probably malformed (symlinks, wrong permissions, wrapping directory). pages.sr.ht silently discards invalid uploads. tar -tzvf site.tar.gz | head to inspect; everything should be -rw-r--r-- and at the top level.

"Published to apex, www doesn't work" You need to publish twice — once per subdomain. There's no implicit www-handling.

TLS error on first visit Cert provisioning is in progress. Wait a few seconds and retry. If it persists for more than a minute, check that DNS actually points at pages.sr.ht.

hut pages publish returns 401 The oauth: grant is wrong or missing. Confirm oauth: pages.sr.ht/PAGES:RW is in the manifest. :RO won't work for publishing — write access is required.

hut: command not found hut isn't installed. Add - hut to packages:. On Debian/Ubuntu, hut isn't in the official repos — install from source or download a release binary as in the Jekyll example.

Site looks fine via curl but browser shows old version Check Cache-Control headers. pages.sr.ht sets reasonable defaults but you may have a service worker, CDN, or browser cache holding old content.

#Pattern: deploy on main branch only

Combine submitter.allow-refs (prevents the build from being submitted at all on non-main branches) with the standard pattern:

image: alpine/edge
packages:
  - hugo
  - hut
oauth: pages.sr.ht/PAGES:RW
environment:
  site: username.srht.site
sources:
  - https://git.sr.ht/~username/my-site
submitter:
  git.sr.ht:
    enabled: true
    allow-refs:
      - refs/heads/master
      - refs/heads/main
tasks:
  - build: |
      cd my-site
      hugo --minify
      tar -czf ../site.tar.gz -C public .
  - publish: |
      hut pages publish -d "$site" site.tar.gz

#Pattern: preview + production from one repo

Split into two manifests under .builds/:

  • .builds/preview.yml — publishes to preview.example.com on any push.
  • .builds/production.yml — publishes to example.com, gated to refs/heads/main via submitter.allow-refs.

Each file has its own submitter: block; sourcehut handles them independently.

#Pattern: regenerate-and-commit

When the build generates content that should also live in the repo (e.g. a changelog or a search index), commit it back:

image: alpine/edge
packages:
  - hut
  - git-cliff
secrets:
  - <ssh-key-secret-uuid>
sources:
  - git@git.sr.ht:~username/my-site
tasks:
  - regenerate: |
      cd my-site
      git cliff -o CHANGELOG.md
      git add CHANGELOG.md
      if git diff --staged --quiet; then complete-build; fi
      git -c user.name='builds.sr.ht' -c user.email='builds@sr.ht' commit -m 'Update CHANGELOG'
      git push -o skip-ci origin HEAD:main

The -o skip-ci push option is required — without it, the commit would trigger another build, which would commit and push, in an infinite loop. The git diff --staged --quiet check avoids an empty commit when nothing changed.