AS.

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

View the repo Skip to the platform

Penwave home page: a painted valley behind the line 'Beyond noise, stories become timeless.', then the latest stories
Penwave home page on a phone: the painted valley hero and the line 'Beyond noise, stories become timeless.'

Screens recreated from the app’s source with sample stories and numbers.

Made for reading.

  1. Open by default.

    Anyone can read. Posts and profiles use optional auth, so nothing is gated until you try to act.

    Penwave home: hero line "Beyond noise, stories become timeless." above the latest stories
  2. Find it by topic.

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

    Penwave explore page filtering stories by the Engineering tag
  3. Then get out of the way.

    One column, serif body at 20px and 1.7 line height, comments one level deep.

    A Penwave post: serif body text, a pull quote and a comment thread

Built for writing.

The Penwave editor: formatting toolbar, a Saved indicator, the draft 'Notes on reading slowly', and the post settings panel
  1. A real editor.

    TipTap with headings, lists, quotes, code blocks, links and images.

  2. It saves as you type.

    The green Saved mark is autosave at work. If a tab closes, a banner offers the draft back.

  3. 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.

A Penwave post on a phone, with like, comment and bookmark controls
  1. Tap the heart.It fills and the count moves before the request lands.
  2. POST /likesOn success the post’s cached data is refreshed, so the count stays honest.
  3. 401?One shared refresh for every request that failed, then a single retry.
  4. 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.

Penwave home on a phone
Penwave explore on a phone
A Penwave post on a phone
Penwave writer dashboard on a phone, with sample stats

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.

  1. Sign inArgon2id with 64 MiB memory, 3 passes, 4 lanes. Unknown users still pay for a dummy hash, so timing gives nothing away.
  2. 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.
  3. 15 minutes laterA 401. Every request waiting on it shares one refresh, so a busy page can’t trip the theft alarm.
  4. RotateInside a transaction: lock the row, revoke the old token, issue a new one, retry the request once.
  5. 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.

  1. Gitleakssecrets in the full git historystops the release
  2. npm auditcritical dependency advisoriesstops the release
  3. HadolintDockerfile lintstops the release
  4. Trivy configTerraform and IaC, high and criticalstops the release
  5. SonarCloudstatic analysisreports, never blocks
  6. Buildxamd64 and arm64 imagesstops the release
  7. Trivy imageimage CVEs, high and criticalstops the release
  8. Deploycontainers recreated one by onestops the release
  9. Migrateprisma migrate deploystops the release
  10. Health checkrolls back to the last image on failurestops the release
GitHub Actions graph for deploy-production.yml: security-scan, then sonar and build jobs for backend and frontend, then deploy, all passed
A real run on tag v1.0.6: the security scan first, SonarCloud and builds for both apps in parallel, then the deploy.

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.

ArgoCD app details for penwave-app: synced to the kubernetes branch with auto-sync enabled, config maps, services, service accounts, a deployment and its replica sets and pods, all healthy
The real app in ArgoCD: synced to the kubernetes branch with auto-sync on, a new ReplicaSet rolling out beside the old one.

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_*
Grafana dashboard for Penwave: request rate, p50, p95 and p99 latency, error rate, active connections, event loop lag, process memory and CPU
From the EKS run, a test deployment with light traffic. Read it as the setup, not a benchmark.

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.

  1. 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, 894d933
  2. 22 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, e274249
  3. 29 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