The problem

An engineering blog needs a setup where an article is an ordinary file next to the code, and publishing requires no admin panel and no database. One more requirement: what I see locally and what ships to the server have to come from the same build code.

The site is built with Astro. 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 stays on the development and build steps only. Everything else follows from that: two separate images, variables that have to be passed before the build rather than after it, and publishing through a commit.

Project structure

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 hot reload
├── docker-compose.yml       # local services
├── Makefile                 # short commands
└── package.json

Astro builds routes from src/pages/ and loads articles through Content Collections. The schema in src/content.config.ts validates the required fields before the build, so an article with a broken frontmatter cannot slip into a release unnoticed: the build fails instead of publishing a page with no date or description.

Production Dockerfile

Since production needs only the build output, the image is built in two stages. The first installs dependencies and runs Astro, the second contains nothing but nginx and the dist/ directory. Node.js and node_modules never reach the final image.

# 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 declared as an ARG rather than a runtime variable, and that matters. Astro puts it into canonical URLs and the sitemap during the build, which bakes the value into static files. Passing it through docker run -e comes too late: the pages are already generated by then.

Dockerfile.dev with hot reload

Development does not need nginx, it needs the opposite set: Node.js, the dev server, and sources the container can see. That is why there is a second Dockerfile.

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"]

Here SITE_URL is a plain ENV: canonical URLs do not matter locally, and rebuilding the image just to change an address would be wasteful.

Docker Compose for development

Compose runs two services from these two images and solves three separate filesystem problems. The bind mount passes source changes to the container. A dedicated volume keeps the host folder from shadowing the container node_modules. The .astro directory is isolated in tmpfs so the dev server lock file from the host does not block the next 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 then available at http://localhost:4321. Changes in src/ show up without an image rebuild. The dev image needs rebuilding after a change to package.json, package-lock.json, or Dockerfile.dev itself.

The preview service builds exactly the image that ships to the server and runs it locally:

docker compose up --build preview

It is available at http://localhost:8081. This is what answers the same-code requirement: the production image can be checked before it reaches the server.

Makefile for common commands

The Makefile adds no logic of its own. It keeps short names for commands that are easy to forget or 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 daily loop stays short:

make up
make logs
make check
make down

How to publish a new article

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

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

Add the 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 in Docker Compose.
pubDate: 2026-09-01
dateLabel: Sep 1, 2026
readingTime: 5 min
category: Docker
tags:
  - Docker
  - Operations
draft: true
---

Briefly explain the task and the expected result.

## Add the healthcheck

Describe the solution and add a verifiable code example.

While draft: true is set, the article stays out of the listing and out of the production build. Before publishing:

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

For a Russian translation, create a file in src/content/writing/ru/. Use the same translationKey and translate the remaining fields. The routeSlug may differ: it only controls the URL of that language.

CI validates the Content Collections, builds the static pages, and creates the production image. After the deployment nginx serves the new version as a whole. No database, migrations, or cache purge are involved in publishing an article.

Traps to watch for

SITE_URL cannot be changed after the build. It is an ARG whose value Astro bakes into canonical URLs and the sitemap. Starting a finished image with a different -e SITE_URL leaves the pages on the old address, and the first place to notice that is usually search results.

--host 0.0.0.0 is mandatory in the dev container. With the default localhost inside the container the port is published, but the browser on the host cannot reach Astro. The symptom looks like a networking or port mapping problem while the cause is a single flag.

The .astro lock file outlives the container. If the directory reaches the host through the same bind mount, the next start runs into a lock left by the previous process. tmpfs solves it by making the directory cease to exist along with the container.

Publishing means a commit and a CI run. An editor without git has no way in, and data that changes more often than the deployment has nowhere to live. For a blog that price is acceptable; for a site with daily content edits it is not.

Conclusions

  • Two images are not duplication but a consequence of production not needing Node.js. The production image holds nginx and dist/, the dev image holds dependencies and the dev server; the sources are all they share.
  • The three Compose volumes solve three different problems, and none of them is redundant: the bind mount gives hot reload, the named volume protects node_modules, and tmpfs removes the lock file conflict.
  • The Content Collections schema is a test for content. A frontmatter error fails the build before deployment instead of turning into a page with no description.
  • The cost of the approach is that publishing goes through git and CI. An article stays an ordinary file.