Penwave
A blogging platform I designed, built and ran on AWS, then rebuilt as a Kubernetes platform.
- Ran on EC2 for 1 to 2 months, retired
- Rebuilt on EKS, verified, spun down


Screens recreated from the app’s source with sample stories and numbers.
Made for reading.
Open by default.
Anyone can read. Posts and profiles use optional auth, so nothing is gated until you try to act.

Find it by topic.
Explore filters by tag and Trending ranks the week. Search runs on a Postgres full-text index.

Then get out of the way.
One column, serif body at 20px and 1.7 line height, comments one level deep.

Built for writing.

A real editor.
TipTap with headings, lists, quotes, code blocks, links and images.
It saves as you type.
The green Saved mark is autosave at work. If a tab closes, a banner offers the draft back.
Settings beside the page.
Cover, excerpt, up to five tags, comments on or off. Drafts can be scheduled; a job publishes them on time.
Quick to respond.

- Tap the heart.It fills and the count moves before the request lands.
- POST /likesOn success the post’s cached data is refreshed, so the count stays honest.
- 401?One shared refresh for every request that failed, then a single retry.
- The gap I left.If that refresh fails too, the heart has already filled. It should roll back; that’s on the list below.
Down to 390 pixels.
The same components below the md breakpoint: nav collapses, cards stack, nothing is a separate mobile site.




Five ramps, two faces.
Every colour is read from the app’s Tailwind config. The ramps run dark to light, and the UI lives at the two ends.
Beyond noise, stories become timeless.
Instrument Serif · every heading and the post body
Penwave is a sanctuary for thoughtful writing, deep ideas, and meaningful digital publishing.
Inter · interface and metadata, weights 300 to 600, no bold
creampage ground (900) and text on dark buttons
beigesecondary buttons and badges
tea greenavatar fallbacks, profile covers
celadonevery border, divider and hover fill
muted tealall text: 100 is the ink, 500 the accent
Under the hood.
Most blogs stop at the editor. I built Penwave like a production system from the first commit, ran it on EC2, then rebuilt the platform on Kubernetes.
A session can’t be stolen twice.
- Sign inArgon2id with 64 MiB memory, 3 passes, 4 lanes. Unknown users still pay for a dummy hash, so timing gives nothing away.
- Two cookiesAn access token for 15 minutes and a refresh token for 7 days, both httpOnly. The refresh cookie is only sent to /api/auth/refresh.
- 15 minutes laterA 401. Every request waiting on it shares one refresh, so a busy page can’t trip the theft alarm.
- RotateInside a transaction: lock the row, revoke the old token, issue a new one, retry the request once.
- Reused tokenTreated as theft. Every session that user has is revoked at once.
Around it: Zod on every route, HTML sanitised on the server before it is stored, separate CSPs for API and frontend, Redis rate limits (100 requests per 15 minutes, 10 for auth, 30 a minute for search).
Ten gates to production.
Pushing to main deploys nothing. A version tag starts the pipeline, and nine of the ten gates can end it. SonarCloud only reports.
- Gitleakssecrets in the full git historystops the release
- npm auditcritical dependency advisoriesstops the release
- HadolintDockerfile lintstops the release
- Trivy configTerraform and IaC, high and criticalstops the release
- SonarCloudstatic analysisreports, never blocks
- Buildxamd64 and arm64 imagesstops the release
- Trivy imageimage CVEs, high and criticalstops the release
- Deploycontainers recreated one by onestops the release
- Migrateprisma migrate deploystops the release
- Health checkrolls back to the last image on failurestops the release

Same app, two platforms.
Phase 2 ran in production at penwave.ddns.net for one to two months, then I retired it on purpose. One host, Compose, managed Postgres and Redis.
One EC2 host running Docker Compose
Edge
- Route 53penwave.ddns.net
- NginxTLS and reverse proxy
Compute
- Frontendone Next.js container
- Backendone Express container
- Migrationsrun by the deploy before the health check
- MonitoringPrometheus, Grafana, Loki, Promtail containers
Data
- PostgreSQL 16RDS
- Redis 7ElastiCache
- S3backups, Terraform state
Delivery
- GitHub Actionsgates, then SSH deploy to the host
Secrets
- Secrets Manageror GitHub secrets, never committed
Built by Terraform: VPC, EC2, RDS, ElastiCache, S3
Try breaking it.
On EKS two things fixed themselves. Kubernetes replaces a pod that dies; ArgoCD undoes changes made by hand. This is a simulation of both, and underneath it the real app tree.
penwave-app · Synced
- penwave-backend-7b6c5b7ff8-x2k9pRunning
- penwave-backend-7b6c5b7ff8-m4q7dRunning
2/2 pods ready. Synced to the kubernetes branch.

Watched, not guessed.
The API exposes Prometheus metrics, scraped every 15 seconds, with IDs in routes normalised so labels can’t explode. Winston writes JSON logs that Promtail ships to Loki.
Two probes: /health says the process is alive, /ready checks Postgres and Redis.
- http_requests_total
- http_request_duration_seconds
- http_active_requests
- http_errors_total
- nodejs_*

Decisions I’d make again.
- Deploys wait for a tag.
- Pushing to main builds nothing. A version tag is a deliberate release, and a rollback always has a named target.
- SonarCloud reports, it doesn’t block.
- Its default gate wants coverage on new code and there is no test suite yet. Gating on it would fail every run for the wrong reason.
- Migrations run before the health check.
- The health check touches the database. Run it first and a release with a new migration fails only because the schema is behind.
- Two probes, not one.
- Liveness never touches the database, so a Postgres outage takes pods out of rotation instead of restarting all of them.
What broke.
15 Jul 2026 · EKS
A failed migration froze every deploy after it.
Migrations ran as an ArgoCD PreSync job, deleted only on success so a failure stayed readable. When one failed, every later sync hung: a Job’s pod template is immutable, so ArgoCD couldn’t replace the leftover. Adding BeforeHookCreation deletes it right before the new one starts. Failed logs now last until the next sync instead of forever.
The commit, 894d93322 Jul 2026 · EC2
Bootstrap died on every new server.
The user-data script writes a small No-IP updater with a heredoc. Unquoted, bash expanded the updater’s own variables while writing the file. They didn’t exist yet, and set -u killed the whole bootstrap. A quoted heredoc fixed it, with the credentials filled in by sed afterwards.
The commit, e27424929 to 31 Jul 2026 · CI
Trivy stopped the first releases.
The image scans failed the builds on high and critical CVEs. Sixteen sat in npm’s own tooling inside node:20-alpine, which the image keeps because npx prisma migrate deploy runs from it, so I listed them in .trivyignore with the reasoning beside each. That did nothing until the workflow actually passed the file to Trivy. The rest I fixed: Next.js 16.2.12, sharp 0.35, and postcss 8.5.18 through an npm override.
The commit, 689e995
What I’d do next.
- Send readers back to the story after they sign up. Today a like that needs an account ends on the home feed.
- Roll the heart back when the session refresh fails.
- Write the test suite, then let SonarCloud gate releases.
- Moderation: reports, a review queue, suspensions. Designed as a flow, not built.
Tech specs.
- Frontend
- Next.js 16, React 19, TypeScript, Tailwind, Radix UI, TanStack Query, Zustand, TipTap
- Backend
- Node 20, Express 4, Prisma 5, Zod, Argon2id, JWT, Winston, prom-client
- Data
- PostgreSQL 16 on RDS, Redis 7 on ElastiCache, S3
- Phase 2
- Terraform, EC2, Docker Compose, Nginx, Route 53, ACM
- Phase 3
- EKS Auto Mode, ArgoCD app-of-apps, Helm, ALB ingress, HPA, PDB, NetworkPolicy, External Secrets, IRSA
- Delivery
- GitHub Actions, Gitleaks, npm audit, Hadolint, Trivy, SonarCloud, multi-arch Buildx, Dependabot
- Observability
- Prometheus, Grafana, Loki, Promtail
- Size
- 10 backend modules, 43 backend and 59 frontend source files