This site is built with Astro. Articles live beside the code as Markdown, local development runs in Docker with hot reload, and the production container serves the generated HTML through nginx.

The files below cover local development, the production build, and article publication. They can serve as a starting point for another Astro project.

How a request is served

During a build, Astro reads pages, components, and Markdown files, then writes the result to dist/. Node.js is not required in production: nginx serves the generated HTML, CSS, and JavaScript.

Markdown + Astro components
           │
           ▼
      npm run build
           │
           ▼
     dist/ directory
           │
           ▼
      nginx :80

Node.js remains in the development and build stages. Only nginx runs in the production container.

Project structure

There is no separate CMS. Content, routes, and infrastructure live in one repository:

.
├── src/
│   ├── components/          # Astro page components
│   ├── content/
│   │   └── writing/
│   │       ├── ru/          # Russian articles
│   │       └── en/          # English articles
│   ├── layouts/             # shared HTML shell
│   ├── pages/               # Astro routes
│   ├── styles/              # global styles
│   └── content.config.ts    # frontmatter schema
├── public/                  # files served without processing
├── tests/e2e/               # Playwright tests
├── Dockerfile               # production build
├── Dockerfile.dev           # development with HMR
├── docker-compose.yml       # local services
├── Makefile                 # short commands
└── package.json

Astro creates routes from src/pages/ and loads articles through Content Collections. The schema in src/content.config.ts validates required fields before the build, so malformed frontmatter cannot quietly reach a release.

Production Dockerfile

A production image can be built in two stages. The first installs dependencies and runs Astro. The second contains only nginx and the generated dist/ directory.

# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY astro.config.mjs tsconfig.json ./
COPY src ./src
COPY public ./public

ARG SITE_URL=https://example.com
ENV SITE_URL=${SITE_URL} \
    ASTRO_TELEMETRY_DISABLED=1

RUN npm run build

FROM nginx:alpine AS runtime

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=5 \
  CMD wget --quiet --tries=1 --spider http://127.0.0.1/healthz || exit 1

SITE_URL is passed at build time because Astro uses it for canonical URLs and the sitemap. Changing this variable after the finished container starts cannot update static pages.

Dockerfile.dev with hot reload

Development does not need nginx. The container starts the Astro dev server and listens on 0.0.0.0 so it is reachable from the host.

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

ENV SITE_URL=http://localhost:4321 \
    ASTRO_TELEMETRY_DISABLED=1

EXPOSE 4321

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "4321"]

The --host 0.0.0.0 flag is essential in Docker. With Astro’s default localhost binding, Docker can publish the port but the browser on the host still cannot reach the server.

Docker Compose for development and preview

The bind mount sends source changes into the container. A separate volume keeps the host mount from hiding the container’s node_modules directory. The .astro directory is isolated in tmpfs, so a host dev-server lock cannot break a later container start.

services:
  dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "4321:4321"
    environment:
      SITE_URL: http://localhost:4321
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    tmpfs:
      - /app/.astro

  preview:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        SITE_URL: http://localhost:8081
    ports:
      - "8081:80"

volumes:
  node_modules:

Start development with:

docker compose up --build dev

The site is available at http://localhost:4321. Changes under src/ appear without rebuilding the image. Rebuild the development image after changing package.json, package-lock.json, or Dockerfile.dev itself.

Run a production preview separately:

docker compose up --build preview

It is available at http://localhost:8081 and uses the same image type that will be deployed to the server.

Makefile for common commands

The Makefile adds no new behavior. It gives short, memorable names to commands that are easy to mistype.

PORT ?= 4321
SITE_URL ?= http://localhost:$(PORT)

.PHONY: install run dev up preview check build down logs

install:
	npm ci

run:
	SITE_URL=$(SITE_URL) npm run dev -- --host 0.0.0.0 --port $(PORT)

dev:
	PORT=$(PORT) SITE_URL=$(SITE_URL) docker compose up --build dev

up:
	PORT=$(PORT) SITE_URL=$(SITE_URL) docker compose up --build -d dev

preview:
	docker compose up --build preview

check:
	npm run check
	npm run build

build:
	SITE_URL=$(SITE_URL) npm run build

logs:
	docker compose logs --tail=100 -f dev

down:
	docker compose down --remove-orphans

The everyday loop is short:

make up
make logs
make check
make down

How to publish a new article

Create a Markdown file in the directory for the target language. For example:

src/content/writing/en/docker-healthcheck.md

Add frontmatter and the article body:

---
locale: en
translationKey: docker-healthcheck
routeSlug: docker-healthcheck
title: How to check container health
description: Add a healthcheck and use it from Docker Compose.
pubDate: 2026-09-01
dateLabel: September 1, 2026
readingTime: 5 min
category: Docker
tags:
  - Docker
  - Operations
draft: true
---

Briefly explain the problem and the expected result.

## Add the healthcheck

Describe the solution and include a testable code example.

While draft: true, the article is absent from the index and production routes. Before publishing it:

  1. finish the text and set draft to false;
  2. run make check;
  3. open /en/writing/docker-healthcheck locally;
  4. check the title, description, date, listings, and mobile layout;
  5. commit the Markdown file and send the change to the main branch.

For a Russian translation, create a file under src/content/writing/ru/. Use the same translationKey but translate the other fields. routeSlug may differ because it controls only that locale’s URL.

What happens after publication

CI validates the Content Collection, generates the static pages, and builds the production image. After deployment, nginx serves the entire new version. Publishing an article requires no database, migrations, or cache invalidation.

This is enough for a small engineering blog: an article remains a regular file, local and production builds share the same code, and the final image can be checked before deployment. The price is that publishing means a commit and a CI run: an editor without git cannot get in, and data that changes more often than you deploy has nowhere to live here.