Hosting on IPFS without running a server

Pin your site to IPFS

I looked at the available options, and only one makes it easy to pin a whole folder/site: Pinata.

Make a free key and save it to PINATA_JWT. Then start writing a GitHub workflow like this:

name: Deploy
  on:
    push:
      branches: [main]
    schedule:
      - cron: '0 0,12 * * *'
    workflow_dispatch:
  jobs:
    deploy:
      runs-on: ubuntu-latest
      # You'll add environment here later
      steps:
        - name: Checkout
          uses: actions/checkout@v4
        - name: Set up Node
          uses: actions/setup-node@v4
          with: { node-version: latest }
        # Fill this in with your build setup
        # - uses: pnpm/action-setup@v4
        #   with: { version: latest }
        # - run: pnpm install
        # - run: pnpm build
        - name: Upload to IPFS
          env:
            PINATA_JWT: ${{ secrets.PINATA_JWT }}
          run: |
            node --input-type=module -e "
              import { readFileSync, readdirSync, writeFileSync } from 'fs';
              import { join, relative } from 'path';
              const files = [];
              function walk(dir) {
                for (const e of readdirSync(dir, { withFileTypes: true })) {
                  const p = join(dir, e.name);
                  if (e.isDirectory()) walk(p); else files.push(p);
                }
              }
              walk('dist');
              const form = new FormData();
              const folder = 'deploy-' + Date.now() + '/';
              for (const f of files) {
                form.append('file', new Blob([readFileSync(f)]), folder + relative('dist', f));
              }
              form.append('pinataMetadata', JSON.stringify({ name: 'deploy-' + Date.now() }));
              const res = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
                method: 'POST',
                headers: { Authorization: 'Bearer ' + process.env.PINATA_JWT },
                body: form,
              });
              const json = await res.json();
              if (!json.IpfsHash) { console.error(JSON.stringify(json)); process.exit(1); }
              writeFileSync('/tmp/cid', json.IpfsHash);
            "
        # You'll add IPNS here later

Give it a stable URL

Changing content changes its content ID (CID), but you probably don't want its URL to also change. The simplest fix is to use IPNS. Set IPNS_KEY to 32 random hex bytes and add this step:

- name: Point IPNS
  id: point
  env:
    IPNS_KEY: ${{ secrets.IPNS_KEY }}
  run: |
    echo "url=$(npx point-ipns $(cat /tmp/cid))" >> $GITHUB_OUTPUT

And to show where it was deployed, add this right before steps:

  environment:
    name: ipfs
    url: ${{ steps.point.outputs.url }}

Other options involve changing your README to always include the latest CID, or using DNSLink (changing your DNS to always point to the latest CID).

Access it

If you didn't notice, your content is available at https://ipfs.io/ipns/[key hash or domain] if you used IPNS or DNSLink, or https://ipfs.io/ipfs/[CID] if you didn't.

But there are more gateways than ipfs.io. This page lists 20, and at least one likely works for you.