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 missingtitleor malformedpubDatefails the build instead of shipping broken. → Content Collections guide. - MDX when you need it. Plain
.mdis enough for most posts;.mdxlets 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 inastro.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 insrc/components/*.astrowith classes inline inclass=. 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 smallrehypeProseClassesplugin inastro.config.mjsattaches Tailwind utilities to the generated<p>,<h2>,<ul>… elements after Shiki highlighting. Authors never put classes in.mdfiles — 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.
- Create a branch off
main. - 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. - Write the post as
src/content/blog/<your-slug>.md(or.mdxfor embedded components). Start from the frontmatter below. - Add a hero image under
src/assets/and reference it with a relative path — it’s optimised at build time bysharp. - 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 thebuildstage with no extra config on the Debian-based Bun images.- Keep
bun.lockcommitted so--frozen-lockfilegives reproducible installs. - The
runtimestage andserver.tsnever 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@5component + Workload Identity (needs Project Factory registration) and drop theCI_BUN_IMAGEoverride.



