azatcloud / devops notes
← back

Architecture microservice

Jun 4, 2026 · 12 min read

Architecture

This document explains how azatcloud is put together after Phase 1: a server-rendered DevOps blog with persistent storage, a token-protected CMS, RSS, SEO tags, and HTMX pagination — all in a single Go binary.

It is written to be read top to bottom: start with the big picture, then drill into each layer and the important flows (reading an article, writing one, migrating the database, starting and stopping the server).


1. Design goals

  • Server-rendered, low-JS. Pages are HTML produced on the server with html/template. HTMX adds small interactive touches (load-more) without a SPA framework. This is fast and SEO-friendly.
  • Storage behind an interface. The HTTP layer never talks to a database directly — it depends on the ArticleRepository interface. That makes the app runnable with zero infrastructure (in-memory store) and swappable to Postgres without touching handlers.
  • Single deployable artifact. Templates and SQL migrations are embedded into the binary with //go:embed. The container is a tiny distroless image.
  • Dependencies point inward. domain is the center and imports nothing app-specific; outer layers (delivery, repository implementations) depend on the inner ones, never the reverse.

2. Big picture

flowchart LR
  subgraph Clients
    B["Browser"]
    C["curl / scripts"]
    F["RSS readers"]
  end

  B --> RT
  C --> RT
  F --> RT

  subgraph App["azatcloud — single Go binary"]
    RT["chi Router + middleware"]
    H["HTTP handlers"]
    RND["render: goldmark + chroma"]
    TPL["html/template, embedded"]
    REPO["ArticleRepository interface"]
    RT --> H
    H --> RND
    H --> TPL
    H --> REPO
  end

  REPO -->|"DATABASE_URL set"| PG[("PostgreSQL")]
  REPO -->|"otherwise"| MEM["in-memory store"]

A request enters the chi router, passes through the middleware chain, and lands on a handler. The handler reads or writes through the repository interface, optionally renders Markdown to HTML, fills a template, and writes the response. Which repository implementation is wired in is decided once, at startup, by the presence of DATABASE_URL.


3. Packages and dependency direction

flowchart TB
  CMD["cmd/server (main)"]
  DEL["internal/delivery/http"]
  RND["internal/render"]
  REPO["internal/repository (interface)"]
  MEM["internal/repository/memory"]
  PG["internal/repository/postgres"]
  DOM["internal/domain"]
  CFG["pkg/config"]
  TXT["pkg/textutil"]
  WEB["web (templates)"]
  MIG["migrations (SQL)"]

  CMD --> DEL
  CMD --> REPO
  CMD --> MEM
  CMD --> PG
  CMD --> CFG
  CMD --> MIG
  DEL --> REPO
  DEL --> RND
  DEL --> CFG
  DEL --> TXT
  DEL --> WEB
  MEM --> DOM
  PG --> DOM
  REPO --> DOM
Package Responsibility
internal/domain The Article entity. No dependencies on anything app-specific.
internal/repository The ArticleRepository interface and ErrNotFound.
internal/repository/memory In-memory implementation (mutex + map). Used for go run with no DB and by the unit tests.
internal/repository/postgres pgx/pgxpool implementation plus the built-in migrator.
internal/render Markdown to HTML via goldmark with chroma syntax highlighting.
internal/delivery/http chi router, public handlers, admin CMS handlers, auth middleware, template sets.
pkg/config Loads configuration from environment variables.
pkg/textutil Slugify and ReadingTime helpers.
web Embedded html/template files.
migrations Embedded *.sql schema migrations.
cmd/server Composition root: load config, pick a repository, run migrations, start the server, shut down gracefully.

main is the only place that knows about concrete implementations; everything else works against interfaces and the standard library.


4. The storage abstraction

The whole point of the interface is that the delivery layer is blind to where data lives. The contract:

type ArticleRepository interface {
    // Public reads (published only)
    ListPublished(ctx, limit, offset int) ([]domain.Article, error)
    CountPublished(ctx) (int, error)
    GetBySlug(ctx, slug string) (domain.Article, error)
    ListByTag(ctx, tag string) ([]domain.Article, error)
    // Admin reads/writes (drafts included)
    ListAll(ctx) ([]domain.Article, error)
    GetByID(ctx, id string) (domain.Article, error)
    Create(ctx, a domain.Article) (domain.Article, error)
    Update(ctx, a domain.Article) (domain.Article, error)
    Delete(ctx, id string) error
}

Two implementations satisfy it:

  • memory keeps a map[string]Article guarded by a sync.RWMutex, hands out IDs like mem-1, and seeds two demo posts. It is the default when no database is configured, and it backs the unit tests (so CI needs no database for them).
  • postgres runs the same operations as SQL over a pgxpool connection pool.

main selects one at startup:

flowchart TD
  S["Start: config.Load()"] --> Q{"DATABASE_URL set?"}
  Q -->|"no"| M["memory.NewArticleRepo()"]
  Q -->|"yes"| P1["pgxpool.NewWithConfig (MaxConns 10, MinConns 2)"]
  P1 --> P2["pool.Ping"]
  P2 --> P3["postgres.Migrate(migrations.FS)"]
  P3 --> P4["postgres.New(pool)"]
  P4 --> P5["repo.SeedIfEmpty"]
  M --> H["NewHandler(repo, logger, cfg)"]
  P5 --> H

Because both implementations return the same domain.Article values and the same ErrNotFound sentinel, handlers treat them identically.


5. Public request lifecycle (reading the home page)

sequenceDiagram
    participant U as Browser
    participant R as chi Router
    participant MW as Middleware chain
    participant H as ListArticles
    participant Repo as ArticleRepository
    participant T as Template

    U->>R: GET /?page=1
    R->>MW: RequestID, RealIP, Logger, Recoverer, Timeout
    MW->>H: dispatch
    H->>Repo: ListPublished(perPage, offset)
    Repo-->>H: list of articles
    H->>Repo: CountPublished()
    Repo-->>H: total
    Note over H: hasMore = offset + len < total
    alt HX-Request header present
        H->>T: render "articleitems" partial only
    else normal request
        H->>T: render full page ("base")
    end
    T-->>U: HTML

The handler computes the page offset, fetches one page of published articles plus the total count, and decides whether a "load more" control is needed. The same handler answers both the full-page request and the HTMX fragment request — the only difference is which template it executes.

Middleware chain

Applied in order to every route:

Order Middleware Purpose
1 RequestID Attach a unique ID to each request for log correlation.
2 RealIP Resolve the client IP from proxy headers.
3 Logger Structured access logging.
4 Recoverer Turn panics into 500s instead of crashing the server.
5 Timeout(30s) Bound request duration.

Routes

Method Path Handler Access
GET /health, /readyz Health public
GET / ListArticles public
GET /articles/{slug} GetArticle public
GET /tags/{tag} ListByTag public
GET /rss RSS public
GET / POST /admin/login AdminLoginForm / AdminLogin public
GET /admin AdminIndex protected
POST /admin/logout AdminLogout protected
GET /admin/articles/new AdminNew protected
POST /admin/articles AdminCreate protected
GET /admin/articles/{id}/edit AdminEdit protected
POST /admin/articles/{id} AdminUpdate protected
POST /admin/articles/{id}/delete AdminDelete protected

Protected routes live in a chi group guarded by the RequireAdmin middleware. The login routes are intentionally outside that group so an unauthenticated user can reach them.


6. Article rendering pipeline

Articles are stored as Markdown and converted to HTML at request time.

flowchart LR
  MD["Article.Content (Markdown)"] --> GM["goldmark parser"]
  GM --> HL["chroma highlighter (monokai)"]
  HL --> HTML["safe HTML"]
  HTML --> TPL["article template"]
  TPL --> OUT["rendered page"]

render.ToHTML runs goldmark with the highlighting extension so fenced code blocks (Go, YAML, Dockerfile, etc.) come out colorized as inline-styled markup. The result is injected into the article template.


7. Data model (Postgres)

Tags are normalized rather than stored as a column, which makes "list by tag" a clean join and avoids duplicated tag strings.

erDiagram
    ARTICLES ||--o{ ARTICLE_TAGS : has
    TAGS ||--o{ ARTICLE_TAGS : labels

    ARTICLES {
        uuid id PK
        text slug UK
        text title
        text content
        text excerpt
        text cover_url
        bool published
        int reading_min
        timestamptz created_at
        timestamptz updated_at
    }
    TAGS {
        uuid id PK
        text name UK
    }
    ARTICLE_TAGS {
        uuid article_id FK
        uuid tag_id FK
    }

Indexes exist on articles(slug), articles(published), and articles(created_at DESC) to support slug lookups, published filtering, and newest-first ordering. article_tags cascades on delete, so removing an article cleans up its links automatically.

Reads

Selects use a LEFT JOIN through article_tags to tags and ARRAY_AGG the tag names back into the Article.Tags slice in a single query, grouped by article id.

Writes and tag sync

Create and Update run inside a transaction. After writing the article row, syncTags rebuilds the tag links: it deletes the existing article_tags rows for that article, upserts each tag name into tags (ON CONFLICT DO NOTHING), then re-inserts the links. If anything fails, the transaction rolls back and the article is left untouched.


8. Migrations

Migrations are plain SQL files in migrations/, embedded into the binary, and applied by a small built-in migrator — no external tool is required to run the app.

flowchart TD
  A["Migrate(pool, embedded FS)"] --> B["CREATE TABLE IF NOT EXISTS schema_migrations"]
  B --> C["List embedded *.up.sql, sorted"]
  C --> D{"version in schema_migrations?"}
  D -->|"yes"| E["skip"]
  D -->|"no"| F["BEGIN tx"]
  F --> G["exec migration SQL"]
  G --> H["INSERT version into schema_migrations"]
  H --> I["COMMIT"]
  E --> J["next file"]
  I --> J

Each applied file is recorded by filename in schema_migrations, so startups are idempotent: already-applied migrations are skipped. For production teams who prefer explicit control, the same files work with the golang-migrate CLI (make migrate); the built-in migrator is the zero-dependency default.


9. The CMS and authentication

Phase 1 uses a single shared secret (ADMIN_TOKEN) to protect the admin area. This is deliberately minimal — Phase 2 replaces it with real user auth (OAuth / JWT). If ADMIN_TOKEN is empty, the admin area returns 503 (disabled).

How RequireAdmin decides

flowchart TD
  R["Request to /admin/*"] --> T{"ADMIN_TOKEN configured?"}
  T -->|"no"| D503["503 admin disabled"]
  T -->|"yes"| TOK["read token: admin_session cookie or Bearer header"]
  TOK --> V{"constant-time match?"}
  V -->|"yes"| N["proceed to handler"]
  V -->|"no"| B{"browser GET?"}
  B -->|"yes"| RED["303 redirect to /admin/login"]
  B -->|"no"| U401["401 unauthorized"]

The token is accepted from either the admin_session cookie (set by the login form) or an Authorization: Bearer header (handy for scripts and curl), and is compared with subtle.ConstantTimeCompare to avoid timing leaks.

Writing an article (admin create)

sequenceDiagram
    participant A as Admin (browser)
    participant MW as RequireAdmin
    participant H as AdminCreate
    participant F as articleFromForm
    participant Repo as ArticleRepository

    A->>MW: POST /admin/articles (form)
    MW-->>A: 303 to /admin/login (if not authed)
    MW->>H: authed
    H->>F: parse form
    Note over F: slug = Slugify(slug or title); reading_min = ReadingTime(content); tags split + dedupe; published = checkbox
    F-->>H: domain.Article
    H->>Repo: Create(article)
    Repo-->>H: saved (with id, timestamps)
    H-->>A: 303 redirect to /admin

articleFromForm is the single place that turns form fields into a domain object: it auto-generates the slug when blank, computes reading time from the content, splits the comma-separated tags (de-duplicated), and reads the published checkbox. AdminUpdate reuses the same function and sets the id from the URL.


10. HTMX pagination (load more)

The home page shows perPage (5) articles. If more exist, a "Load more" button is rendered. Clicking it asks the server for the next page as a fragment and swaps it in place.

flowchart TD
  P1["GET / -> full page: 5 articles + Load more button"]
  P1 --> CLICK["user clicks Load more"]
  CLICK --> HX["hx-get /?page=2 with HX-Request header"]
  HX --> SRV["ListArticles renders ONLY the articleitems partial"]
  SRV --> SWAP["hx-swap=outerHTML replaces the button with: next 5 articles + a new Load more button"]
  SWAP --> CLICK

The trick is that the partial template (articleitems) renders both the article items and the next button together. Because the button swaps itself with outerHTML, each click appends the next batch and leaves a fresh button — until hasMore is false, at which point no button is emitted and the chain ends. The same handler serves the full page and the fragment; it checks the HX-Request header to decide which template to execute.


11. Templates

Templates are embedded from web/templates and composed into named sets in NewHandler. A base layout defines the shared shell (head, header, footer) and a content block; each page template fills that block.

Set Files Used by
list base + list + partials home page, tag page
partial partials HTMX load-more fragment
single base + article single article
adminList base + admin_list /admin
adminForm base + admin_form new / edit forms
adminLogn base + admin_login login page

The base layout also emits Open Graph / SEO <meta> tags from a Meta value the handler passes in, plus a <link rel="alternate" ... /rss> and the HTMX script tag.


12. RSS and SEO

  • RSS (/rss): the handler pulls the 20 most recent published articles and builds an RSS 2.0 feed with gorilla/feeds, served as application/rss+xml. Item links are absolute, built from BASE_URL.
  • SEO / Open Graph: every page passes a Meta struct (title, description, canonical URL, type) that the base template renders into <meta> tags so links unfurl nicely and search engines get clean metadata.

13. Configuration

All configuration comes from environment variables (see .env.example):

Variable Default Effect
ADDR :8080 HTTP listen address
ENV development environment label
BASE_URL http://localhost:8080 absolute URLs for OG tags and RSS
DATABASE_URL empty Postgres DSN; empty selects the in-memory store
ADMIN_TOKEN empty admin secret; empty disables the admin area

14. Startup and graceful shutdown

sequenceDiagram
    participant OS
    participant M as main / run
    participant DB as Postgres
    participant SRV as http.Server

    M->>M: config.Load()
    M->>DB: buildRepo -> pool, Ping, Migrate, SeedIfEmpty
    DB-->>M: repository ready
    M->>SRV: NewHandler + NewRouter, ListenAndServe (goroutine)
    Note over M: signal.NotifyContext(SIGINT, SIGTERM)
    OS-->>M: SIGINT / SIGTERM
    M->>SRV: Shutdown(ctx, 10s timeout)
    SRV-->>M: in-flight requests drained
    M->>DB: pool.Close (deferred cleanup)
    M-->>OS: exit 0

The server runs in a goroutine; main blocks on either a server error or an OS signal. On a signal it calls Shutdown with a 10-second deadline so in-flight requests finish, then the deferred cleanup closes the connection pool.


15. Build and delivery

flowchart LR
  SRC["source + embedded templates and migrations"] --> B1["build stage: golang 1.26, CGO disabled, static binary"]
  B1 --> B2["runtime stage: distroless static, nonroot"]
  B2 --> IMG["image"]
  IMG --> REG["private GitLab Container Registry"]

The Dockerfile is multi-stage: it compiles a static binary, then copies only that binary into a distroless image (no shell, no package manager, runs as non-root). The GitLab pipeline runs lint -> test -> build; the test job attaches a postgres:16 service for integration tests, and the build job pushes the image to the project's private registry on pushes to main.


16. How to extend

  • Add a field to articles: add it to domain.Article, create a new migrations/0000XX_*.up.sql (and a .down.sql), update the Postgres select columns / insert / update, and update the memory store and templates. The migrator applies the new file automatically on next startup.
  • Add an endpoint: add a handler method in internal/delivery/http, then register the route in NewRouter (inside the admin group if it needs protection).
  • Swap to sqlc: the Postgres repository is hand-written pgx today. Adding a sqlc.yaml and query.sql to generate the query layer is a drop-in replacement behind the same ArticleRepository interface — nothing else changes.
  • Real auth (Phase 2): replace RequireAdmin and the shared-token login with user accounts, sessions/JWT, and roles. The route group and handler signatures stay the same.