A simple site on Astro, hosted free on DigitalOcean, with IndieBob Free for the blog
Four static pages, a blog that is written and published in IndieBob and baked into the site at build time, analytics with no cookies, an RSS feed — and a bill of €0. Every command and file on this page was run once for real: it is how www.surfzone.co is built and hosted today.
What you get
| The site | Astro, static output, one dependency plus the tracker. Home, about, contact, privacy, and /blog/. |
| Hosting | DigitalOcean App Platform, a static site component on the free tier, deployed on every push, your own domain with a certificate. |
| IndieBob Free | The tracker (page views, clicks, referrers, no cookies), a facts sheet Bob writes from, three AI drafts a month in Content Studio, one content key, and the webhook that rebuilds your site when you publish. |
| Cost | €0 for the site, €0 for IndieBob Free. The domain is the one thing you pay for, wherever you buy it. |
The shape matters more than the stack: posts live in IndieBob, the site fetches them at build time, and nothing runs on a server. A static site has no runtime to pay for, nothing to patch, and no way to leak the content key — it never reaches a visitor's browser.
1 · The site
Create it
The minimal template is one page and no framework. Node 22 or newer.
Terminal
npm create astro@latest my-site -- --template minimal
cd my-site
npm install @indiebob/trackerFour pages, and where the blog goes
One layout owns the <head> and the tracker; pages own their own copy and CSS. The privacy page — the one that explains the tracker — is the one page that does not load it.
src/
src/
├── layouts/
│ └── BaseLayout.astro # head, fonts, tokens, the tracker
├── components/
│ └── Tracker.astro # one IndieBob script (step 3)
├── lib/
│ ├── site.mjs # the site's facts: base URL, project id
│ └── blog.mjs # posts from IndieBob at build time (step 4)
└── pages/
├── index.astro # home
├── about.astro
├── contact.astro
├── privacy.astro # loads no tracker: <BaseLayout analytics={false}>
├── blog/
│ ├── index.astro # /blog/
│ └── [slug].astro # /blog/<slug>/
└── rss.xml.js # built from the same postsastro.config.mjs
Two settings do real work. output: 'static' keeps the DigitalOcean component a static site (a server output would need a paid service). Directory format with a trailing slash gives every route a real index.html, which is what a static host serves without a catch-all — and the /blog/<slug>/ shape IndieBob's sitemap assumes.
astro.config.mjs
// astro.config.mjs
import { defineConfig } from 'astro/config'
// Static only. Nothing here runs on a server, so the DigitalOcean app
// stays a free static site.
//
// trailingSlash + format: every page is a directory index, every link
// ends in a slash — the shape IndieBob's own feed and sitemap assume
// (…/blog/<slug>/).
export default defineConfig({
site: 'https://www.example.com',
output: 'static',
trailingSlash: 'always',
build: {
format: 'directory',
inlineStylesheets: 'always',
},
})2 · Host it free on DigitalOcean
App Platform's starter tier hosts static sites for free — no card-per-month, no container. The whole app is one YAML file in the repo, so the hosting is version-controlled with the site.
.do/app.yaml
.do/app.yaml
# .do/app.yaml — a static site on App Platform's free tier.
# No catchall_document: Astro's directory output gives every route
# a real index.html.
name: my-site
region: fra
domains:
- domain: www.example.com
type: PRIMARY
zone: example.com
- domain: example.com
type: ALIAS
zone: example.com
alerts:
- rule: DEPLOYMENT_FAILED
- rule: DOMAIN_FAILED
static_sites:
- name: my-site
environment_slug: node-js
source_dir: /
gitlab: # or github:
repo: you/my-site
branch: main
deploy_on_push: true
build_command: npm ci && npm run build
output_dir: distbuild_command: npm ci && npm run build and output_dir: dist are the two keys App Platform's autodetect guesses; writing them down stops it guessing differently one day.
Push to deploy
Create the app once from the spec with doctl (or paste the same YAML into the console's "Create App → Edit spec"). Connect GitLab or GitHub when asked. With deploy_on_push: true, every push to main runs the build and publishes dist/.
Terminal
# First time: create the app from the spec
doctl apps create --spec .do/app.yaml
# Later changes to the spec
doctl apps update <app-id> --spec .do/app.yaml
# Before applying again, refresh the file from the live spec — update
# REPLACES the whole env list, and the content key (step 3) lives there:
doctl apps spec get <app-id> > .do/app.yamlYour domain
The two domains entries above are the whole configuration: www is primary, the apex is an alias that redirects to it. At your DNS host, point www as a CNAME at the app's *.ondigitalocean.app hostname; for the apex use an ALIAS/ANAME record to the same hostname, or move the zone to DigitalOcean DNS, which handles both. App Platform issues the certificate. The DOMAIN_FAILED alert is the one you want on — it fires when DNS is not what the spec expects.
3 · IndieBob Free
Sign up, create the project
Sign in with Google, name the project, and give it the site's domain (https://www.example.com). The project's proj_… id is on the Connect page; it is public by design — it sits in every page's tracker call — so it can live in the repo.
The tracker, in one Astro script
@indiebob/tracker is under 5 KB gzipped, sets no cookies, and is bundled by Vite from npm — a pinned version and no third-party script request for an ad blocker to drop. One page() per load is right for a site with no client router. The project id crosses from the build into the browser with define:vars; the module script then reads it from window.
src/lib/site.mjs
// src/lib/site.mjs — the one place the site's facts live.
export const SITE = {
base: 'https://www.example.com',
name: 'My site',
ogDefault: 'https://www.example.com/og-default.png',
}
// The IndieBob project id is public by nature — it is in every page's
// tracker call — so it lives here; an env var can override it for a fork.
export const INDIEBOB_PROJECT = process.env.INDIEBOB_PROJECT_ID || 'proj_xxxxxxxxxxxxxxxx'src/components/Tracker.astro
---
// src/components/Tracker.astro
// One page() per full page load (there is no client router here), plus a
// named event for whatever `data-track` names a link carries. autoDetect
// already reports outbound clicks and form submits on its own.
//
// Bundled by Vite from the npm package, not the CDN: pinned version, no
// third-party script request for an ad blocker to drop.
import { INDIEBOB_PROJECT } from '../lib/site.mjs'
---
<script define:vars={{ projectId: INDIEBOB_PROJECT }}>
window.__ibProject = projectId
</script>
<script>
import { IndieBob } from '@indiebob/tracker'
const projectId = window.__ibProject
if (projectId && /^proj_[a-z0-9]{16}$/.test(projectId)) {
const bob = IndieBob.init({ projectId, autoDetect: true })
bob.page()
for (const a of document.querySelectorAll('a[data-track]')) {
a.addEventListener('click', () => bob.track(a.dataset.track))
}
}
</script>src/layouts/BaseLayout.astro
<!-- src/layouts/BaseLayout.astro, the end of <body> -->
<body>
<slot />
{analytics && <Tracker />}
</body> Visit the site once; the SDK step on Connect lights, and the live event view shows page and outbound_click arriving. The tracker batches for five seconds — a check that waits less sees no request. Full API: /docs/sdk.
The facts sheet
Everything Bob writes is grounded in a short markdown sheet about the product — what it is, who it is for, features, what it does not do, pricing — and nothing else. Connect › Facts hands you a prompt to run inside your own repo with whatever coding assistant you use; it reads the code and writes the sheet, so the sheet cannot invent a feature. Paste the result. For a studio site covering several products, one block per product with its own "does not" list is the shape that worked.
Write and publish a post
Content › New post. Give Bob a topic or write it yourself in the editor; the SEO score updates as you type; a cover is composed at publish. Free is three AI drafts a month. Press Publish — the post is now a published snapshot the Content API serves, and on Free it ends with a one-line "Written with IndieBob" credit that the API also declares as data, so a receiving site can style it or strip it knowingly.
Mint the content key
Settings › Developers › Create key. Free includes one key; it starts with ib_pk_, is shown once, and is stored as a hash. Put it in the DigitalOcean app as a build-time, encrypted environment variable named INDIEBOB_CONTENT_API_KEY (App → Settings → your component → Environment Variables), and in a gitignored .env locally. It is never committed and never reaches a visitor.
Check it
curl https://www.indiebob.com/api/content/v1/posts \
-H "Authorization: Bearer ib_pk_your_key"
# → { "data": [ { "slug", "title", "excerpt", "ogImageUrl", "publishedAt", … } ],
# "pagination": { "page": 1, "limit": 20, "total": 2, "totalPages": 1 } }4 · The blog, built from IndieBob at build time
src/lib/blog.mjs
One module fetches the list, then each post in full, once per build; the index, the post pages and the feed share the result. The deliberate part is the failure mode: a build without the key throws instead of shipping an empty blog — a site once came within a push of wiping every post that way. The escape hatch, ALLOW_EMPTY_BLOG=1, is what the DigitalOcean app carries only until the key exists.
src/lib/blog.mjs
// src/lib/blog.mjs — posts are fetched at BUILD time and baked into
// static HTML; nothing here runs in a visitor's browser.
const API_BASE = 'https://www.indiebob.com'
const API_KEY = process.env.INDIEBOB_CONTENT_API_KEY
const apiGet = async (suffix) => {
const res = await fetch(`${API_BASE}/api/content/v1${suffix}`, {
headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
})
if (!res.ok) throw new Error(`GET ${suffix} → HTTP ${res.status}`)
return res.json()
}
async function fetchPosts () {
// A key-less build must FAIL, not quietly ship an empty blog. The
// escape hatch is explicit — it is what the DigitalOcean app carries
// until the content key exists.
if (!API_KEY) {
if (process.env.ALLOW_EMPTY_BLOG === '1') return []
throw new Error('INDIEBOB_CONTENT_API_KEY is not set — refusing to build a blog with no posts.')
}
const collected = []
let page = 1, totalPages = 1
do {
const list = await apiGet(`/posts?limit=50&page=${page}`)
collected.push(...(list.data ?? []))
totalPages = list.pagination?.totalPages ?? 1
page += 1
} while (page <= totalPages)
const detailed = []
for (const s of collected) {
const { data: raw } = await apiGet(`/posts/${encodeURIComponent(s.slug)}`)
detailed.push({
slug: raw.slug,
title: raw.title ?? '',
excerpt: raw.excerpt ?? '',
html: raw.bodyHtml ?? '',
publishedAt: raw.publishedAt ?? null,
cover: raw.ogImageUrl ?? null,
// Free-plan posts end with a "Written with IndieBob" line, declared
// as data; keep it in the body and style it muted, or strip the
// element marked data-indiebob-credit.
writtenWithCredit: Boolean(raw.writtenWithCredit),
})
}
detailed.sort((a, b) => new Date(b.publishedAt ?? 0) - new Date(a.publishedAt ?? 0))
console.log(`✓ blog: ${detailed.length} post(s) from IndieBob`)
return detailed
}
// One fetch per build, shared by the index, the post pages and the feed.
let pending = null
export const getPosts = () => (pending ??= fetchPosts())/blog/ and /blog/[slug]/
getStaticPaths turns the fetched posts into pages. The body is IndieBob's published HTML, sanitised on the server with an allowlist, rendered with set:html.
src/pages/blog/index.astro
---
// src/pages/blog/index.astro
import BaseLayout from '../../layouts/BaseLayout.astro'
import { getPosts } from '../../lib/blog.mjs'
const posts = await getPosts()
---
<BaseLayout title="Notes" description="What we are building, what broke, what it earned." canonicalPath="/blog/">
<h1>Notes</h1>
{posts.length === 0
? <p>Nothing yet. The first note lands when there is something worth telling.</p>
: <ul>{posts.map((p) => (
<li><a href={`/blog/${p.slug}/`}>{p.title}</a></li>
))}</ul>}
</BaseLayout>src/pages/blog/[slug].astro
---
// src/pages/blog/[slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro'
import { getPosts } from '../../lib/blog.mjs'
import { SITE } from '../../lib/site.mjs'
export async function getStaticPaths () {
const posts = await getPosts()
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }))
}
const { post } = Astro.props
---
<BaseLayout
title={post.title}
description={post.excerpt}
canonicalPath={`/blog/${post.slug}/`}
ogType="article"
ogImage={post.cover || SITE.ogDefault}
>
<article>
<h1>{post.title}</h1>
{post.cover && <img src={post.cover} alt="" width="1200" height="630" />}
{/* IndieBob's published snapshot, sanitised server-side with an allowlist. */}
<Fragment set:html={post.html} />
</article>
</BaseLayout>The cover per post
Every post carries an ogImageUrl: the cover you picked or generated in the editor, or — when you picked none — a 1200×630 card IndieBob composes at https://www.indiebob.com/api/og/<project>/<post> from the title and your brand. The page above puts it in og:image and at the top of the article; nothing to generate on your side.
RSS from the same posts
IndieBob serves a feed too, but it needs the bearer key, so it cannot be the URL you publish. Build your own from the same getPosts() — it costs nothing extra and can never disagree with the pages.
src/pages/rss.xml.js
// src/pages/rss.xml.js — our own feed, built from the same posts the
// pages are built from. IndieBob's feed endpoint needs the bearer key,
// so it cannot be the public feed URL; this one can.
import { getPosts } from '../lib/blog.mjs'
import { SITE } from '../lib/site.mjs'
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
export async function GET () {
const posts = await getPosts()
const items = posts.map((p) => ` <item>
<title>${esc(p.title)}</title>
<link>${SITE.base}/blog/${p.slug}/</link>
<guid isPermaLink="true">${SITE.base}/blog/${p.slug}/</guid>
${p.publishedAt ? `<pubDate>${new Date(p.publishedAt).toUTCString()}</pubDate>` : ''}
<description>${esc(p.excerpt)}</description>
</item>`).join('\n')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${SITE.name} notes</title>
<link>${SITE.base}/blog/</link>
<atom:link href="${SITE.base}/rss.xml" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>
`
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
}Rebuild when you publish
A static site only knows about a new post when it is rebuilt. Settings › Developers › Webhooks (on Free) sends content.published and content.updated, signed, to a URL you give it — point it at whatever triggers your build (a small receiver that runs doctl apps create-deployment, or a CI pipeline trigger). Until you have one, a push to main rebuilds, and so does "Deploy" in the DigitalOcean console. Publishing is a promise the site keeps at the next build; the post hub says Live only when the address answers. Payload and signature: /docs/api.
What Plus adds
Everything on this page is Free. Plus (€9 a month) is for when the blog is running and you want to know what it earned: Earned — revenue from Stripe and which post or source each paying customer came from; campaigns with UTM links and ROI; posting to X from the social generator (drafting the posts is Free); 75 drafts a month instead of 3, 200 personalised outreach emails instead of 20, more content keys, and the "Written with IndieBob" credit becomes optional.
The bill
| DigitalOcean App Platform, static site | €0 |
| IndieBob Free | €0 |
| Astro, the tracker, the AI that writes the drafts | €0 |
| Per month | €0 |
The AI drafts on Free run on our own solar-powered, battery-backed AI farm, which is why they cost nothing. Questions about this guide: [email protected].
The two documents this guide leans on: Tracker SDK and the Content API. To have an agent run the publishing instead of you, see Agents & MCP.