Building a Git-Backed Blog API in Go
Git already gives me drafts (branches), history (commits), and content review (pull requests). I wanted an API that exposes that structure directly without inventing another datastore, so I rewrote the blog backend in Go and treated a Git repo as the only source of truth.
Stack
Everything lives in mono/blog-api with a small package layout:
cmd/server: entrypointinternal/config: env parsinginternal/gitrepo: read-only git clientinternal/content: builds filesystem-like responsesinternal/server: HTTP handlers + middleware
API
The API stayed intentionally tiny:
GET /healthandHEAD /health– readiness checks used by Traefik and DigitalOcean monitorsGET /v1/branches– enumerates branches fromoriginGET /v1/entries– lists the root tree for the requested branch (defaults tomain)GET /v1/entries/:path– drills into any subtree or returns blob contents if the path points to a file
Responses mirror the old service so clients didn’t need to change. The OpenAPI spec (now at mono/blog-api/docs/openapi.yml) still documents the same shapes.
Git workflow
LOCAL_CONTENT=1 flips the service into “trust the mounted repo” mode, which is perfect for writing locally: bind-mount your checkout to /content and the API serves it directly.
When LOCAL_CONTENT is unset, the service opens /content with go-git and fetches from origin. It prefers SSH, but if there’s no agent (common in containers) it automatically retries over HTTPS using CONTENT_REMOTE_URL and updates the remote config for the next fetch. Every request walks the HEAD tree, so you’re always seeing exactly what’s in git without extra caching layers.
Docker targets
The Dockerfile exposes dev and prod stages:
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod if [ -f go.sum ]; then go mod download; fi
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/blog-api ./cmd/server
RUN git clone https://github.com/photodialectic/git-blog.git /content
FROM golang:1.23 AS dev
WORKDIR /app
COPY --from=builder /content /content
COPY . .
ENV PORT=10101
CMD ["go", "run", "./cmd/server"]
FROM gcr.io/distroless/base-debian12:nonroot AS prod
WORKDIR /app
COPY --from=builder /out/blog-api /app/blog-api
COPY --from=builder /content /content
ENV PORT=10101
ENTRYPOINT ["/app/blog-api"]
Dev builds keep the toolchain plus source so I can docker exec and go run inside the container. Prod images stay tiny (distroless + static binary) but still ship the initial /content clone from the build stage.
Compose files point to the right target per environment (prod for stage/prod, dev for dev/local) and local stacks mount the repo + optional /content bind.
Request flow
- Handler parses
branch(defaults tomain). - Content service calls
gitrepo.Lookup, which fetches if needed and walks the tree. - If the path maps to a blob, it streams the file contents. Otherwise it builds a nested
{name,type,children}structure while filtering.git*entries. - Responses are JSON and mimic a filesystem.
Unit tests cover the content layer (filesystem mode, git mode, blob vs directory) so regressions show up when I go test ./....
Wrap-up
The API now serves every blog page you’re reading. Draft a post in git, push a branch, hit /v1/entries?branch=your-draft, and the content shows up immediately. No migrations, no CMS logins—just git.