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 /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK \
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:
- finish the text and set
drafttofalse; - run
make check; - open
/en/writing/docker-healthchecklocally; - check the title, description, date, listings, and mobile layout;
- 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.