This is a walkthrough of how the blog you’re reading actually builds and deploys. Astro’s output is a folder of static HTML, so there’s no application runtime in production — GitLab Pages serves the files. Bun does the building, and a tiny Bun server exists only for when we want to run the built site as a container.

Everything here is in the repo: astro.config.mjs, src/styles/global.css, Dockerfile, docker-bake.hcl, server.ts, and .gitlab-ci.yml.

Why Astro

A team engineering blog is mostly prose with the occasional interactive bit. We wanted the authoring experience of Markdown, the escape hatch of components when a post needs one, and an output that’s cheap and safe to host. Astro fits that shape:

  • Static by default, zero JS by default. Astro renders pages to HTML at build time and ships no client JavaScript unless a component opts in. A reading-heavy blog stays fast because there’s no framework runtime to hydrate. See Astro’s rendering modes.
  • Content Collections. Posts are Markdown/MDX files in src/content/blog/, and their frontmatter is type-checked against a Zod schema (src/content.config.ts). A missing title or malformed pubDate fails the build instead of shipping broken. → Content Collections guide.
  • MDX when you need it. Plain .md is enough for most posts; .mdx lets a post embed components inline (this one renders Mermaid diagrams that way). → MDX integration.
  • Islands, not a SPA. Interactivity is opt-in per component (Astro Islands), so a single widget never drags a whole framework onto every page.
  • First-class tooling we already want. Image optimisation (sharp), an RSS feed, and a sitemap are official integrations, wired in astro.config.mjs.

Full docs live at docs.astro.build.

Styling with Tailwind

Styling is Tailwind CSS v4, wired through the official Vite plugin — no PostCSS config, no tailwind.config.js. The whole hook-up is two lines in astro.config.mjs:

import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  // ...
  vite: { plugins: [tailwindcss()] },
});

Everything else lives in src/styles/global.css. Tailwind v4 is CSS-first: design tokens are declared in a @theme/:root block (brand colours, fonts, radius) and dark mode is a custom variant driven by data-theme on <html> rather than the OS media query:

@import 'tailwindcss';

@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

:root {
  --accent: #0028be;   /* Webedia blue */
  --font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif;
  --radius: 1rem;
  /* ...tokens consumed by Tailwind utilities */
}

Two rules keep the styling consistent:

  • Utilities in markup, never @apply. Reusable UI is small Astro components in src/components/*.astro with classes inline in class=. We don’t hand-roll component classes with @apply.
  • Prose is classed by a rehype plugin. Markdown/MDX HTML has no place for class= attributes, so a small rehypeProseClasses plugin in astro.config.mjs attaches Tailwind utilities to the generated <p>, <h2>, <ul>… elements after Shiki highlighting. Authors never put classes in .md files — the prose is styled centrally.

Styling & Tailwind in Astro.

Two ways to serve the same dist/

Astro builds directory-style routes (/blog/post//blog/post/index.html). Once dist/ exists, we serve it two ways:

  • GitLab Pages for the deployed blog — pure static hosting, nothing running.
  • A Bun static server (server.ts) for the local/prod Docker image, so the container serves the built site the same way in either place.

The container runtime: a Bun static server

The server.ts handler resolves Astro’s routing and falls back to 404.html:

// server.ts
const DIST = "./dist";
const port = Number(Bun.env.PORT ?? 4321);

Bun.serve({
  port,
  hostname: "0.0.0.0",
  async fetch(req) {
    const url = new URL(req.url);
    const pathname = decodeURIComponent(url.pathname);

    const candidates = pathname.endsWith("/")
      ? [pathname + "index.html"]
      : [pathname, pathname + "/index.html", pathname + ".html"];

    for (const c of candidates) {
      const file = Bun.file(DIST + c);
      if (await file.exists()) return new Response(file);
    }

    const notFound = Bun.file(DIST + "/404.html");
    if (await notFound.exists()) return new Response(notFound, { status: 404 });
    return new Response("Not Found", { status: 404 });
  },
});

Bun.file() streams the response and sets Content-Type from the extension for you, so there’s nothing else to wire up.

The build: a multi-stage Dockerfile

The Dockerfile has five stages: deps, dev, build, runtime, and export. Locally and in prod we build on Webedia’s foundation Bun images (GCP Artifact Registry) — the -dev image for build tooling, the slim image for runtime — but every image ref is an ARG, so CI can swap them out:

# syntax=docker/dockerfile:1
ARG BUN_REGISTRY=europe-docker.pkg.dev/prj-shd-prd-registry-8ed4/foundation/bun
ARG BUN_VERSION=1.3
ARG DEPS_IMAGE=${BUN_REGISTRY}:${BUN_VERSION}-dev
ARG RUNTIME_IMAGE=${BUN_REGISTRY}:${BUN_VERSION}

# --- Install dependencies ---
FROM ${DEPS_IMAGE} AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile

# --- Dev: live-reload Astro dev server (source bind-mounted at run time) ---
FROM ${DEPS_IMAGE} AS dev
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN chown -R www-data:www-data /app   # foundation entrypoint runs as www-data
EXPOSE 4321
CMD ["bun", "run", "dev", "--host", "0.0.0.0", "--force"]

# --- Build the static site ---
FROM ${DEPS_IMAGE} AS build
WORKDIR /app
ARG SITE_URL=""
ENV SITE_URL=${SITE_URL}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build

# --- Runtime: serve dist/ with the tiny Bun server (local / prod container) ---
FROM ${RUNTIME_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4321
COPY --from=build /app/dist ./dist
COPY server.ts ./
EXPOSE 4321
CMD ["bun", "run", "server.ts"]

# --- Export: just the built site, for GitLab Pages ---
FROM scratch AS export
COPY --from=build /app/dist /

SITE_URL is baked in at build time so the sitemap and RSS emit absolute URLs; CI passes $CI_PAGES_URL. The export stage is the trick for Pages — a scratch image whose entire filesystem is dist/, so buildx can dump it straight to a local directory with nothing else attached.

The dev stage is the odd one out — it doesn’t build anything. It just carries node_modules and runs astro dev, so the source can be bind-mounted over it for live reload (see Running it locally). --force clears a stale dev-server lock left behind if a container was killed without a clean shutdown, and the chown hands /app to www-data — the user the foundation image’s entrypoint drops to — so Astro can write its generated files.

Lifting dist/ out with buildx bake

docker-bake.hcl defines the site target: build the export stage and write its filesystem to ./public (the directory GitLab Pages publishes):

target "site" {
  context    = "."
  dockerfile = "Dockerfile"
  target     = "export"
  output     = ["type=local,dest=public"]
  args = {
    BUN_REGISTRY = BUN_REGISTRY
    BUN_VERSION  = BUN_VERSION
    SITE_URL     = SITE_URL
  }
}

type=local means no image is produced — buildx just copies the scratch stage’s files onto the host. Registry layer cache is wired via --set on the CLI in CI, so local bake runs never touch the registry.

Deploying to GitLab Pages

The deployed blog is hosted on GitLab Pages — static hosting built into GitLab, so there’s no separate infra to run. .gitlab-ci.yml has two relevant jobs. build_site runs the bake inside Docker-in-Docker and hands public/ off as an artifact; pages (a reserved GitLab job name) publishes it.

CI can’t reach the GCP Artifact Registry — no Workload Identity wired here yet — so it overrides DEPS_IMAGE to the public oven/bun image. The foundation image stays the default for local/prod where gcloud auth is present.

variables:
  BUILDX_CACHE: $CI_REGISTRY_IMAGE/cache:buildkit
  CI_BUN_IMAGE: oven/bun:1.3   # public image; CI has no GCP registry creds

build_site:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - >
      docker buildx bake -f docker-bake.hcl site
      --set site.args.SITE_URL=$CI_PAGES_URL
      --set site.args.DEPS_IMAGE=$CI_BUN_IMAGE
      --set site.cache-from=type=registry,ref=$BUILDX_CACHE
      --set site.cache-to=type=registry,ref=$BUILDX_CACHE,mode=max
  artifacts:
    paths: [public]
    expire_in: 1 hour

pages:
  stage: deploy
  needs: [build_site]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  artifacts:
    paths: [public]

Merge requests build the site (so you catch a broken build in review) but only the default branch deploys. Layer cache lives in the project’s own Container Registry ($CI_REGISTRY_IMAGE/cache:buildkit), so most CI runs skip the Bun install and Astro rebuild of unchanged layers.

The whole path, end to end:

flowchart LR
    A[deps<br/>bun install] -->|node_modules| B[build<br/>bun run build]
    A -.->|node_modules| G[dev<br/>astro dev + watch]:::teal
    B -->|dist/| C[runtime<br/>Bun server]:::teal
    B -->|dist/| D[export<br/>FROM scratch]:::violet
    S[server.ts] --> C
    D -->|type=local| E[public/]
    E --> F([GitLab Pages]):::accent

Running it locally

compose.yaml builds the runtime target and publishes it straight on a port — no proxy, no TLS to wire up:

services:
  blog:
    build:
      context: .
      target: runtime
    environment:
      - PORT=4321
    ports:
      - "4321:4321"
    restart: unless-stopped

That’s the production image running locally, so it rebuilds on every source change. For live reload while writing a post, compose.dev.yaml is a local override (copied to compose.override.yaml by make up) that flips the build to the dev target and bind-mounts the source over /app:

services:
  blog:
    build:
      target: dev          # Astro dev server instead of the static build
    volumes:
      - .:/app             # source bind-mounted for hot reload
      - /app/node_modules  # keep the image's installed deps
make up   # http://localhost:4321, Astro watching the mounted source

Edit an .mdx and the page reloads. The anonymous node_modules volume keeps the deps baked into the dev image, so adding a dependency means a rebuild (docker compose build) rather than an at-startup install.

How to contribute

Anyone on the team can publish an article — a post is just a Markdown/MDX file, no code changes needed.

  1. Create a branch off main.
  2. Add your author entry (once) to src/data/authors.ts — pick a stable key (e.g. pem) and add your name, role, bio, and links.
  3. Write the post as src/content/blog/<your-slug>.md (or .mdx for embedded components). Start from the frontmatter below.
  4. Add a hero image under src/assets/ and reference it with a relative path — it’s optimised at build time by sharp.
  5. Preview locally, then open a Merge Request. CI deploys to GitLab Pages on merge to main.
---
title: 'Your article title'
description: 'One or two sentences shown in listings and social cards.'
pubDate: 'Jul 7 2026'
category: 'Frontend' # free text; groups the post on /blog/category/<slug>
author: 'polem' # your key from src/data/authors.ts
heroImage: '../../assets/your-image.jpg' # optional
heroImageAlt: 'Describe the image for screen readers' # '' if decorative
updatedDate: 'Jul 10 2026' # optional
---

Your content in Markdown. Fenced code blocks are highlighted (light/dark),
and ` ```mermaid ` fences render as diagrams.

Frontmatter is type-checked against the schema in src/content.config.ts, so the build fails if a required field (title, description, pubDate) is missing or malformed. Remember the styling rule: don’t put classes in .md files — prose is styled centrally by rehypeProseClasses.

Notes

  • sharp (Astro’s image optimizer) runs under Bun during the build stage with no extra config on the Debian-based Bun images.
  • Keep bun.lock committed so --frozen-lockfile gives reproducible installs.
  • The runtime stage and server.ts never ship to Pages — production is static files only. They exist purely for the container path.
  • To move CI onto the foundation Bun image, wire the auth@5 component + Workload Identity (needs Project Factory registration) and drop the CI_BUN_IMAGE override.