A self-hosted view counter for a static Hugo blog โ built from scratch
in Python, containerized, signed, deployed to k3s via GitOps, and
integrated into the Hugo frontend. No third-party services. Every piece
runs in the cluster.
Architecture
Visitor loads post
โ
Hugo frontend JS โ POST /views/{slug} โ view-counter API
โ GET /views/{slug} โ display count
โ
FastAPI (Python)
โ
PostgreSQL (CNPG)
2 instances, WAL archiving to MinIO
[project]name="view-counter"version="0.1.0"description="View counter microservice for uclab.dev"requires-python=">=3.12"dependencies=["asyncpg>=0.31.0","fastapi>=0.135.1","python-dotenv>=1.2.2","uvicorn>=0.42.0",][dependency-groups]dev=["httpx>=0.28.1","pytest>=9.0.2","pytest-asyncio>=1.3.0",][tool.pytest.ini_options]asyncio_mode="auto"testpaths=["tests"]
src/view_counter/database.py:
importasyncpgimportos_pool:asyncpg.Pool|None=Noneasyncdefget_pool()->asyncpg.Pool:global_poolif_poolisNone:_pool=awaitasyncpg.create_pool(host=os.getenv("DB_HOST","localhost"),port=int(os.getenv("DB_PORT",5432)),database=os.getenv("DB_NAME","view-counter"),user=os.getenv("DB_USER","view-counter"),password=os.getenv("DB_PASSWORD",""),min_size=2,max_size=10,)return_poolasyncdefinit_db()->None:try:pool=awaitget_pool()asyncwithpool.acquire()asconn:awaitconn.execute("""
CREATE TABLE IF NOT EXISTS views (
slug TEXT PRIMARY KEY,
count BIGINT NOT NULL DEFAULT 0,
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")exceptExceptionase:print(f"WARNING: DB not available: {e}")print("App starting without DB โ endpoints will fail until DB is reachable")asyncdefclose_pool()->None:global_poolif_poolisnotNone:await_pool.close()_pool=None
src/view_counter/routes.py:
fromfastapiimportAPIRouterfrom.databaseimportget_poolrouter=APIRouter()@router.get("/health")asyncdefhealth():return{"status":"ok"}@router.get("/views")asyncdefget_all_views():pool=awaitget_pool()asyncwithpool.acquire()asconn:rows=awaitconn.fetch("SELECT slug, count FROM views ORDER BY count DESC")return[{"slug":r["slug"],"count":r["count"]}forrinrows]@router.get("/views/{slug:path}")asyncdefget_views(slug:str):pool=awaitget_pool()asyncwithpool.acquire()asconn:row=awaitconn.fetchrow("SELECT count FROM views WHERE slug = $1",slug)return{"slug":slug,"count":row["count"]ifrowelse0}@router.post("/views/{slug:path}")asyncdefincrement_views(slug:str):pool=awaitget_pool()asyncwithpool.acquire()asconn:row=awaitconn.fetchrow("""
INSERT INTO views (slug, count, last_seen)
VALUES ($1, 1, now())
ON CONFLICT (slug) DO UPDATE
SET count = views.count + 1,
last_seen = now()
RETURNING count
""",slug)return{"slug":slug,"count":row["count"]}
The {slug:path} parameter type tells FastAPI to accept forward slashes
in the slug โ necessary because Hugo post URLs include the full path
like posts/my-post-title/.
The service has no public port. Traffic reaches it exclusively through
the Cloudflare Tunnel. Add the route to the cloudflared ConfigMap in
the Flux repo:
Reading time comes from Hugo’s native .ReadingTime โ zero extra
infrastructure. The view counter is a small JavaScript snippet that
calls the API on every post load.
Add to the aside block in layouts/_default/single.html, above the
date:
<p><span>{{ .ReadingTime }} min read</span> ยท <spanid="view-count"></span></p><script>(async()=>{constslug='{{ .RelPermalink }}'.replace(/^\//,'');constbase='{{ .Site.Params.viewCounterHost }}';try{awaitfetch(base+'/views/'+slug,{method:'POST'});constres=awaitfetch(base+'/views/'+slug);constdata=awaitres.json();document.getElementById('view-count').textContent=data.count+' views';}catch(e){document.getElementById('view-count').textContent='';}})();</script>
The script POSTs to increment then GETs to display. The try/catch
silently hides any failure โ a microservice hiccup never affects the
reading experience. The leading slash is stripped from .RelPermalink
since FastAPI’s {slug:path} parameter handles the remaining segments.
Verification
# Health checkcurl https://viewcounter.uclab.dev/health
# {"status":"ok"}# Increment a countercurl -X POST "https://viewcounter.uclab.dev/views/posts/my-post/"# {"slug":"posts/my-post/","count":1}# Read a countercurl "https://viewcounter.uclab.dev/views/posts/my-post/"# {"slug":"posts/my-post/","count":1}# All counters ranked by viewscurl "https://viewcounter.uclab.dev/views"# [{"slug":"posts/my-post/","count":1}]# Database cluster healthkubectl cnpg status view-counter-db -n view-counter
What This Demonstrates
This project covers the full DevOps lifecycle for a greenfield
microservice:
Code โ Python FastAPI structured as a proper package with uv for
dependency management. Async database access via asyncpg with connection
pooling. CORS configured for the exact origins that need access.
Graceful startup without a database connection so the container passes
readiness probes even before the CNPG cluster is ready.
Container โ Multi-stage Dockerfile with uv as the build tool. Final
image is 50MB, runs as non-root with a read-only root filesystem. The
CI runner builds natively for the target architecture.
Supply chain โ Every image is signed with Cosign before being
pushed. The signing key lives in Vault and is injected into the CI
runner’s ephemeral /tmp for the duration of the signing step then
deleted. The ClusterImagePolicy enforces signature verification at
admission time for namespaces that carry the
policy.sigstore.dev/include: "true" label.
GitOps โ The CI pipeline never deploys directly to the cluster. It
updates the image digest in the pi5cluster repo and Flux reconciles the
change on its next sync cycle. The cluster state is always the source of
truth in Git. A rolling update replaces the old pod only after the new
one passes its readiness probe.
Database โ PostgreSQL managed by CloudNative-PG with two instances,
streaming replication across two NUC nodes, and WAL archiving to MinIO
for point-in-time recovery. The schema is created at application startup
via asyncpg โ no migration tooling needed for a single table.
Secrets โ Credentials stored in Vault, synced into the cluster via
External Secrets Operator with a 15 second refresh interval. The
ExternalSecret manifest is safe to commit to Git โ it contains only a
reference to the Vault path, never the value.
Networking โ ClusterIP service, no NodePort or LoadBalancer. The
only ingress path is through the Cloudflare Tunnel which terminates TLS
and enforces zero-trust access policies before traffic reaches the pod.
Observability โ CNPG enablePodMonitor: true wires Prometheus
scraping automatically. Liveness and readiness probes on /health give
Kubernetes accurate signal for traffic routing and restart decisions.