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.
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:
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.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.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.pages.sr.ht is strict about what it accepts:
.tar.gz (gzipped tar). Plain .tar, .tar.bz2, .tar.zst, .zip are not accepted.chmod +x anything before tarring.index.html at the tar's root, not nested in a directory.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 flagshut 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.
DNS setup depends on whether it's an apex domain (example.com) or a subdomain (www.example.com, docs.example.com):
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.
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.
After DNS resolves, publish to the domain by name:
hut pages publish -d example.com site.tar.gz
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.
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.
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 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.
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.
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
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.
# List all your published sites
hut pages list
# Remove a site
hut pages unpublish -d example.com
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:
script-src 'self' 'unsafe-eval' 'unsafe-inline', style-src 'self' 'unsafe-inline').<script> and <style> work (because of 'unsafe-inline' / 'unsafe-eval').script-src).img-src https:, media-src 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.
"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.
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
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.
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.