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 /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 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:
- finish the text and change
drafttofalse; - run
make check; - open
/en/writing/docker-healthchecklocally; - check the title, description, date, listings, and the mobile layout;
- 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, andtmpfsremoves 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.