<!--
  canonical-url: https://www.avonture.be/blog/running-docusaurus-with-docker
  published:     2025-11-11
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Running Docusaurus using Docker

> Streamline Docusaurus blog development! Build a single Docker image for production and use VS Code Dev Containers for consistent, fast, and isolated editing.

---
![Running Docusaurus using Docker](/img/v2/docusaurus_using_docker.webp)

**TL;DR**

This guide details how to streamline Docusaurus blog development using Docker. It outlines creating a single Docker image for production builds and leveraging VS Code Dev Containers for a consistent, fast, and isolated development environment. The setup enhances the workflow with integrated tools like LanguageTool and Code Spell Checker for improved content quality.

Streamline your Docusaurus workflow with a clean, reproducible setup. In this updated guide, you'll learn how to maintain a Docusaurus blog using a single Docker image, VSCode DevContainers, and a workflow that makes both production builds and day-to-day writing fast and reliable.

Eighteen months ago, I published [Encapsulate an entire Docusaurus site in a Docker image](/blog/docker-docusaurus-prod) but, since then, I've refined the approach, especially around separating **production** and **development** environments cleanly (see [One Docker Image for Production and Devcontainers - The Clean Way](/blog/docker-prod-devcontainer)).

With this setup:

* You run `make build` once to create a base Docker image.
* You launch your editing environment with `make devcontainer`.
* VSCode automatically builds and opens an isolated container with everything configured.

*If Docusaurus itself is new to you, start with [Running Docusaurus with Docker](/blog/docusaurus-docker); and once the site is built, [GitHub - Use Actions to deploy this blog](/blog/github-action) takes care of publishing it.*

Let’s walk through the complete workflow.

**Quick Jump:** [Retrieve a Docusaurus blog](#retrieve-a-docusaurus-blog) | [Create the base Docker image](#create-the-base-docker-image)

Here's where we're headed: `make build` once, `make up`, and the blog is already running.

> Screenshot — https\://localhost/blog
>
> ![The blog is running](./images/running_prod.webp)

A few commands below and you'll be there too.

**What we'll learn here:**

1. Retrieve a Docusaurus blog,

2. Create a Docker image to run the blog

3. Open the project in VSCode using DevContainers

4. Enhance the environment with extensions like LanguageTool, Code Spell Checker or Markdown lint.

## Retrieve a Docusaurus blog

If you already have a Docusaurus blog, just skip this chapter. For demonstration purposes, we'll use my friend's blog, [Docux](http://docuxlab.com/) who is publishing very useful Docusaurus components and plugins.

Grab a copy of his blog by running commands below:

```bash
$ cd /tmp && git clone git@github.com:Juniors017/docux-blog.git && cd docux-blog
```

## Create the base Docker image

If you're new to Docusaurus, you might wonder: *How can I just run the blog locally?*. The fast answer is: by running a self-contained Docker image and we'll learn that in this chapter.

Please first open VSCode by running `code .`:

```bash
$ code .
```

You should now see the project structure in your editor.

![The blog opened in VSCode](./images/initial_after_clone.webp)

### Create required files

In the root of the project, create the following files:

**Project: /tmp/docux-blog**

**compose.yaml**

```yaml
name: blog

services:
  docusaurus:
    build:
      context: .
      target: ${TARGET:-development}
      dockerfile: Dockerfile
    container_name: docusaurus
    ports:
      - "443:443"

    command: sleep infinity
```

**Dockerfile**

```docker
# syntax=docker/dockerfile:1.6

# By default, 1000:1000 but can be different like 1002:1002.
# There parameters are initialized using the "make build" command.
ARG OS_USERID=1000
ARG OS_GROUPID=1000

# Don't change, we'll reuse the standard user (the "node" user is
# the one is node Docker image having user 1000:1000
ARG OS_USERNAME="node"

# Root folder where Docusaurus will be installed in the Docker image
ARG APP_HOME="/opt/docusaurus"

# ─────────────────────────────────────────────────────────────
# 🧱 Base Image: Devcontainer Node.js environment
# ─────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/devcontainers/javascript-node:20-bookworm AS base

# Install bash and bash-completion (required for Devcontainer shell features)
RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt/lists \
    set -eux; \
    apt-get update && \
    apt-get install -y --no-install-recommends \
        bash \
        bash-completion; \
    rm -rf /var/lib/apt/lists/*

# If the host userid/groupid is different from 1000:1000 then update the
# existing node user to these IDs. This is not needed here for the production
# image but well for devcontainer to remove permissions problems while synchronizing
# with the host
ARG OS_USERID
ARG OS_GROUPID
ARG OS_USERNAME

RUN set -eux && \
    if [ "$OS_USERID" != 1000 ] || [ "$OS_GROUPID" != "1000" ]; then \
        groupmod -g "$OS_GROUPID" node; \
        usermod -u "$OS_USERID" -g "$OS_GROUPID" "${OS_USERNAME}"; \
    fi

# Add the node user to the docker group (create it if needed) to allow,
# in devcontainer, to run Docker-in-Docker actions if needed
RUN set -eux && \
    groupadd docker || true && \
    usermod -aG docker "${OS_USERNAME}"

ARG APP_HOME
RUN set -eux && \
    # Make sure the home folder exists
    mkdir -p "${APP_HOME}" && \
    # Pre-create the .docusaurus folder to avoid permission issues during build
    # Note: this folder will be stored in memory (tmpfs) when running devcontainer
    mkdir -p "${APP_HOME}/.docusaurus" && \
    chown -R "${OS_USERNAME}":"${OS_USERNAME}" "${APP_HOME}"

# Switch to non-root user for all subsequent stages
USER "${OS_USERNAME}"
WORKDIR "${APP_HOME}"

# ─────────────────────────────────────────────────────────────
# 📦 Stage 1: Dependency Installation
# ─────────────────────────────────────────────────────────────
FROM base AS dependencies

ARG APP_HOME
ARG OS_USERNAME
ARG OS_USERID
ARG OS_GROUPID

# Configure the cache folder for yarn so we can reuse it in our Devcontainer later on
ENV YARN_CACHE_FOLDER=/home/${OS_USERNAME}/.cache/yarn/v6
RUN set -eux && \
    mkdir -p "${YARN_CACHE_FOLDER}" && \
    chown -R "${OS_USERNAME}":"${OS_USERNAME}" "${YARN_CACHE_FOLDER}"

# Copy package manifests and lockfiles for dependency installation
COPY --chown="${OS_USERNAME}":"${OS_USERNAME}" package.json package-*.* yarn*.* ./

USER "${OS_USERNAME}"

# Install dependencies using Yarn with cache mount
RUN --mount=type=cache,target=${YARN_CACHE_FOLDER},uid=${OS_USERID},gid=${OS_GROUPID} \
    yarn install --frozen-lockfile --prefer-offline

# ─────────────────────────────────────────────────────────────
# 🧪 Stage 2: Development Environment Setup
#
# This is the target to use when building a Devcontainer
# ─────────────────────────────────────────────────────────────
FROM base AS development

ARG APP_HOME

# Copy full project source code and installed node_modules from dependencies stage
COPY --chown="${OS_USERNAME}":"${OS_USERNAME}" . .
COPY --chown="${OS_USERNAME}":"${OS_USERNAME}" --from=dependencies "${APP_HOME}"/node_modules ./node_modules

# ─────────────────────────────────────────────────────────────
# 🏗️ Stage 3: Static Site Build
#
# This stage is used to build the static site; when
# "TARGET=production make build" is fired.
# ─────────────────────────────────────────────────────────────
FROM development AS build

# Build the Docusaurus site into static HTML/CSS/JS
RUN yarn build

# ─────────────────────────────────────────────────────────────
# 🚀 Stage 4: Production Image (Nginx)
#
# Minimal Nginx image for serving static files)
# ─────────────────────────────────────────────────────────────
FROM nginx:stable-alpine AS production

# Clean default Nginx content
RUN rm -rf /usr/share/nginx/html/*

ARG APP_HOME
RUN mkdir -p "${APP_HOME}"

# Copy built static site from build stage into Nginx's web root
COPY --from=build "${APP_HOME}"/build /usr/share/nginx/html

# Add TLS certificates (for HTTPS support)
RUN mkdir -p /etc/nginx/certs
COPY localhost.pem /etc/nginx/certs/
COPY localhost-key.pem /etc/nginx/certs/

# Replace default Nginx config with custom one
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Expose HTTP and HTTPS ports
EXPOSE 80 443

# Start Nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
```

**localhost-key.pem**

```pem
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQD1oKfgLKVSX43z
IOLZ73MAoXISQ58x6xBpJdq84bX9MFTJTR2TI7oVUAxich9r7bithemycKyCbAGr
9qu3oSx8z0j3R/dCNhy/UCHpto5EVOoh98L924SReS+jWTSwBhLTb77602JaTcBC
rwuiD+pzYM62+ip2TvCK+luUf6Jfi7XATw16HaEaWlsC9FOwG7tCSe4yxlj9GmY3
60+yyrjg2WEWsLsiDrW689gjIPlIkwWTL3RL9hBE0n+jLSCPqYOhzXyFxmNwHw1/
YkUt6OjQWKpEdFEim/k2fbkeS6t2q+mCzrc063Wqe2sFTiGvMqrfN2238ra3S+dI
PRuZ39G9AgMBAAECggEAWjKOsxFx/Be+xqMy6pi1gGvIudA/K1OIiTQvaODxtXIE
EI/S5m9eK+ljQttpknaPNJlSQgaCMrp6uq0wTZRQA90SjEvPDm9UiyRf77Tr9UfB
Wovi/NmZitoXlkJxyJnSW/ZTCUSKf2LB0Tr6aJ6zl37bKfIfmaZPSpm4+4ULGU46
V1uFbbulG5Lm0ykcjwtaznkPnysxXEVnVO9TgGsbj+wSGcA8RxDj3kpQxnjqgt8J
mDMD3yJZ/WstsJtsVcwkSBt4aoWLYGD5JzF7uNikR7/4gDqcAjrwO/+or2bGwdA8
OyvZNSDaXOhkALPV/9jxhT6HhPVK9f1/vYIBc2k5wQKBgQD94xnkifeOn8LhiKSh
PL7Wt8Sd1cqAqAaSod22BBpu3ic4fFDE3zbGUKuULXEJUzzJr5m2m5mybBpY8ESR
lQkBZAsL7WxIIrLVjedmRCFRxe/gfoQQd1VQfi+odEXTCul/ESOOAY/Ht29AuVCE
m2IB2TDTq0pwukjqxxwK5ZUq+QKBgQD3q/U4f3c/e8L/otyf4EUGIsk+UdkuxxTE
TqXWnb9UoxHJ6SF0yjldLkOYTle+Ioz4GCCUagYbQkBXQBOLf4wKoaszv8vCA0a3
SumeJizNu+x/erhcce4mnUl71eBAR0M2j59px9Wh2Lj4kOFaZ8xOPeoK9d0DuZDJ
MzkztSKp5QKBgA18N47dCSopMDe8g0oRKO3MuwPLLENjKV42fMlDNIg5AopMiBm8
5XLpLAPl2gAtzKfFzPtXDnXcqhWZRXsaGG1be/65MKwwM6GGNQlIiyCFy/T3o1og
jIaG/U8MRf0ssjROm73aNWHdS2p7rVwtyKnieGZ1zbFpI6BczQ+DHEY5AoGAe9hl
x7mdZIY7bN8AWYWonjfYz6vX4Rc1oC//p0C8QtND+uXzj9XrN1gB/u/12iKmwQw3
hl857HmFeNuWqmO+IDbVSAw5FCvVOaRhC27lan709I3mEHv77R0reJvrBdlcl7Tb
ruZ5QfQAEnNH7JAIznsRRCfLO6iX6LQPf3as/a0CgYBg2qKsj9I3NJYpNahC24wN
lMLyamiXBNWyoZ5jMXHN3DTCYtFiEc5XepHTTKhvGA5GV/XR8/3oL+sXCi7u5PoB
9e4P2l7mZQ+p3naC52UVpbC02yO6kfdeKysLbu5yZaURVWYRDFl38SznG+opW+vG
fwNFiSi7rFvEDd5mezJclw==
-----END PRIVATE KEY-----
```

**localhost.pem**

```pem
-----BEGIN CERTIFICATE-----
MIIEKDCCApCgAwIBAgIRAKY08xOJWP5zy47GwVEvFOgwDQYJKoZIhvcNAQELBQAw
cTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSMwIQYDVQQLDBpjaHJp
c3RvcGhlQEFWT05UVVJFLVJBQ09VUjEqMCgGA1UEAwwhbWtjZXJ0IGNocmlzdG9w
aGVAQVZPTlRVUkUtUkFDT1VSMB4XDTI0MDgxOTEyNTkzNloXDTI2MTExOTEzNTkz
NlowTjEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMSMw
IQYDVQQLDBpjaHJpc3RvcGhlQEFWT05UVVJFLVJBQ09VUjCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAPWgp+AspVJfjfMg4tnvcwChchJDnzHrEGkl2rzh
tf0wVMlNHZMjuhVQDGJyH2vtuK2F6bJwrIJsAav2q7ehLHzPSPdH90I2HL9QIem2
jkRU6iH3wv3bhJF5L6NZNLAGEtNvvvrTYlpNwEKvC6IP6nNgzrb6KnZO8Ir6W5R/
ol+LtcBPDXodoRpaWwL0U7Abu0JJ7jLGWP0aZjfrT7LKuODZYRawuyIOtbrz2CMg
+UiTBZMvdEv2EETSf6MtII+pg6HNfIXGY3AfDX9iRS3o6NBYqkR0USKb+TZ9uR5L
q3ar6YLOtzTrdap7awVOIa8yqt83bbfytrdL50g9G5nf0b0CAwEAAaNeMFwwDgYD
VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFPBV
fPspKHOownZkdv5UZq4w9i8hMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG
9w0BAQsFAAOCAYEAPGdW3uHyzKDqBNOXSUNppPGRGGGB+Seai3km//szxUEwe4Nx
jFNkOFDAos4zOD1hx977WaDkTN1XlUW0jmC+vi7mljKJYaNLr5YVbQyythabDG0o
RsbkAafn04kcIYG6LmjB8uRegJNRcj3dco5odjt1KDQgIOmu4PwMHgdop5ptV4jS
L5y2vj89/vdKu6eMroBWDecVcY4cbM1FUZIG/HtJhHwNfJq2NgSm25l13ipf6axf
F7gJO2zCooSScLQfTbsKFm+4EmYLuEfJLPFUr3aRsZn4ZPdMkl+D6l7MT0qNhwer
51JB1BsJtEG9RbjC8Lbf34gwdJOkslZfL7YjPfDuqWhjOvvMJvPr0STzFEaPQRJj
0kIU3P9RvYb1xiooZVfpt+hGxqs2IR+YHvwQZW3epTy8LeB+CmxIqdxbYLLmVYXf
3VCbwGF6Qxv8uzmcB+q6fe24vY1U7cbMebXxOCZuxIr5xNXZ5f0NE87VfaKuE7UN
Rj/0IWKjfyKtIOcd
-----END CERTIFICATE-----
```

**makefile**

```
# cspell:ignore beautifulsoup4,repost,maintag,oyaml

SHELL:=bash

# Load .env file if there is one (if none, no error will be fired)
-include .env

DOCKER_UID:=$(shell id -u)
DOCKER_GID:=$(shell id -g)

# If not defined on the command line, default to development
# Use "TARGET=production make build" f.i. to switch to PROD and then build the PROD image
TARGET ?= development

# region - Helpers
COLOR_RED=31
COLOR_GREEN=32
COLOR_YELLOW=33
COLOR_WHITE=37

_RED:="\033[1;${COLOR_RED}m%s\033[0m %s\n"
_GREEN:="\033[1;${COLOR_GREEN}m%s\033[0m %s\n"
_YELLOW:="\033[1;${COLOR_YELLOW}m%s\033[0m %s\n"
_WHITE:="\033[1;${COLOR_WHITE}m%s\033[0m %s\n"
# endregion

default: help

.PHONY: help
help: ## Show the help with the list of commands
	@clear
	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n\n"} /^[a-zA-Z_-]+:.*?##/ { printf "  \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[0;33m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
	@echo ""

##@ Blog                    Blog helpers (!!  For production, please always add "TARGET=production" before like "TARGET=production make build" !!)

.PHONY: bash build down remove up
bash: _bash-$(TARGET)  ## Open an interactive shell in the Docker container
build: _build-$(TARGET) ## Build dev/prod image. You can pass CLI flags via ARGS like ARGS="--no-cache"
down: _down-$(TARGET) ## Stop the container
remove: _remove-$(TARGET) ## Remove the image
up: _up-$(TARGET) ## Create the Docker container.

.PHONY: _bash-development
_bash-development:
	@printf $(_YELLOW) "Start an interactive shell in the Docker DEVELOPMENT container; type exit to quit"
	DOCKER_UID=${DOCKER_UID} DOCKER_GID=${DOCKER_GID} docker compose run --rm docusaurus /bin/bash

.PHONY: _bash-production
_bash-production:
	@printf $(_YELLOW) "Start an interactive shell in the Docker PROD container; type exit to quit"
	docker run -it my/blog:node /bin/sh

# Build the DEV image i.e. using docker compose and with all our environment variables
# Pre-create folders to avoid permission problems while building devcontainer

.PHONY: _ensure-host-volumes
_ensure-host-volumes:
	@for dir in .docusaurus node_modules; do \
        if [ ! -d "$$dir" ]; then \
            printf $(_YELLOW) "-> Creating $$dir directory on host..."; \
            mkdir -p "$$dir"; \
        fi; \
    done

.PHONY: _build-development
_build-development: _ensure-host-volumes
	@printf $(_YELLOW) "-> Building DEVELOPMENT image using docker compose..."
	@printf $(_WHITE) "Use 'TARGET=PRODUCTION make build' for the PRODUCTION image."
	@echo ""
	DOCKER_UID=${DOCKER_UID} DOCKER_GID=${DOCKER_GID} docker compose build ${ARGS}
	@echo ""
	@printf $(_GREEN) "Development build finished. Now, continue by running \"make devcontainer\"."
	@echo ""

# Build the PROD image i.e. a light nginx image
.PHONY: _build-production
_build-production:
	@printf $(_YELLOW) "-> Building PRODUCTION stand alone image..."
	@echo ""
	docker build --tag my/blog:latest --target production ${ARGS} .
	@echo ""
	@printf $(_GREEN) "Production build finished. You can now publish this image."

.PHONY: devcontainer
devcontainer: ## Open the blog in Visual Studio Code in devcontainer
	@printf $(_YELLOW) "Open the blog in Visual Studio Code in devcontainer"
	code .

.PHONY: _down-development
_down-development:
	docker run --rm -it --user root -v $${PWD}/:/project -w /project node /bin/bash -c "yarn run clear"
	DOCKER_UID=${DOCKER_UID} DOCKER_GID=${DOCKER_GID} docker compose down

.PHONY: _down-production
_down-production:
	@docker stop blog > /dev/null 2>&1 || true

.PHONY: _remove-development
_remove-development:
    #--volumes
	DOCKER_UID=${DOCKER_UID} DOCKER_GID=${DOCKER_GID} docker compose down --remove-orphans --rmi all
    #-docker image rmi blog-docusaurus
    # Remove the old build folder if present
	@rm -rf .docusaurus
    # Don't remove these files / folders so the next build won't download anymore dependencies
    # @rm -rf node_modules *.lock package-lock.json
	@echo ""

.PHONY: _remove-production
_remove-production: _down-production
	@docker rmi --force my/blog:latest

.PHONY: up
up: _up-$(TARGET) ## Create the Docker container.

.PHONY: _up-development
_up-development:
	@printf $(_YELLOW) "-> No need to create the container; just run 'make devcontainer' instead."
	@printf $(_WHITE) "Use 'TARGET=production make up' to start the PRODUCTION environment."
	@echo ""

.PHONY: _up-production
_up-production:
	@printf $(_YELLOW) "-> Create the PRODUCTION stand alone image..."
	@echo ""
	@-docker stop blog > /dev/null 2>&1 || true
	@-docker rm blog > /dev/null 2>&1 || true
	docker run -d --publish 443:443 --name blog my/blog:latest
	@echo ""
	@printf $(_YELLOW) "Open the PROD blog (https://localhost)"

.PHONY: push
push: ## Push the image on Docker hub (only when TARGET=production in .env)
    # Make sure you're already logged in on docker.com, if not run "docker login" and proceed to make an authentication.
# 	docker build --tag my/blog:latest --target final ${ARGS} .
	docker image push my/blog:latest
```

**nginx.conf**

```conf
# nginx.conf

# 1. HTTP to HTTPS Redirection (Optional but recommended)
server {
    listen 80;
    server_name localhost;
    # Redirect all HTTP traffic to HTTPS, preserving the path and query string
    return 301 https://$host$request_uri;
}

# 2. HTTPS Server Block (The main site configuration)
server {
    listen 443 ssl http2;

    # Root directory where Docusaurus files are located
    root /usr/share/nginx/html;

    # Index file
    index index.html index.htm;

    server_name localhost;

    # SSL Configuration (using the copied certificates)
    ssl_certificate /etc/nginx/certs/localhost.pem;
    ssl_certificate_key /etc/nginx/certs/localhost-key.pem;

    # Basic SSL settings (Recommended)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # --- Docusaurus Home Page Redirection ---
    # When a user hits the root (https://localhost/), redirect them to /blog
    location = / {
        return 301 /blog;
    }

    # Docusaurus requires rewrites for client-side routing on all other paths
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache control for static assets (optional, but good practice)
    location ~* \.(?:css|js|jpe?g|gif|png|ico|woff2?|ttf|eot|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
}
```

### Build and run the production image

Once the files are in place, in a console, please run `make` and you'll get a screen like this:

![Getting the list of targets](./images/make_help.webp)

If `make` isn't yet installed on your machine, you'll get an error so please make sure to first install `make`:

```bash
$ sudo apt-get update && sudo apt-get install make
```

Now we have to run two commands, the first one will create a Docker image for production i.e. where everything is included in the image.

```bash
$ TARGET=production make build
```

You'll get a screen like this one and you'll have to wait something like 80 seconds until the image is built.

![Running make build for PROD](./images/make_build.webp)

This done, please run the second command to create a running instance of the image i.e. a container:

```bash
$ TARGET=production make up
```

This will be very fast and you'll see on the console a message telling `Open the PROD blog (https://localhost)`.

And you know what? It's already done: just surf to `https://localhost` and be surprised — the blog is running, the same result already teased at the top of this article.

> **What have we done?:**
>
> By running `TARGET=production make build` we've created a standalone version of the blog running on nginx. We've encapsulated all articles from the blog (as Markdown files) and we've fired all the required commands to generate the static version of the blog. We've *parked* these files in a temporary location.
>
> We've created the final image by using nginx and by copying static files created earlier in the image.
>
> By running `TARGET=production make up` we've created a running instance (called `container`) of that image and we've asked our operating system to access the container on port `443` (the one of the `https` protocol). That's why, if we access `https://localhost` we get the website.

## Using DevContainers for Development

Building the production image is nice and helpful if you want to publish it on Docker Hub, but we also need a smooth editing workflow. Using VS Code DevContainers, we get a fully isolated environment tailored for Docusaurus.

First, run `make build` (notice, we don't specify `TARGET=production` here). It'll be extremely fast since everything was already created before.

Next, run `make devcontainer` to open VSCode and nothing happens right now: it's normal, we need to create additional files.

> **Run both in one command:**
>
> Under Linux, you can run `make build && make devcontainer` to run both in just one command.

### Create the .devcontainer folder

We'll need to create a new `.devcontainer` folder with a few files:

**Project: /tmp/docux-blog**

**.devcontainer/.env**

```
OS_USERID=1000
OS_GROUPID=1000
```

**.devcontainer/bootstrap.sh**

```bash
#!/usr/bin/env bash
set -euo pipefail

# Load helper functions
source "/opt/docusaurus/.devcontainer/bash_helpers.sh"

# Run the Docusaurus watcher
HTTPS=true \
SSL_CRT_FILE=localhost.pem \
SSL_KEY_FILE=localhost-key.pem \
yarn start --host 0.0.0.0
```

**.devcontainer/bash\_helpers.sh**

```bash
#!/bin/bash
set -euo pipefail

BASHRC="/home/${OS_USERNAME:-node}/.bashrc"
MARKER="# DEV CONTAINER WELCOME MESSAGE"

if ! grep -qxF "$MARKER" "$BASHRC"; then
    cat <<EOF >> "$BASHRC"
$MARKER

BLOG_HOST_DIR='${BLOG_HOST_DIR:-}'

alias ls='ls -alh'
alias upgrade='yarn upgrade && yarn upgrade @docusaurus/core@latest @docusaurus/plugin-ideal-image@latest @docusaurus/plugin-sitemap@latest @docusaurus/preset-classic@latest @docusaurus/theme-search-algolia@latest @docusaurus/module-type-aliases@latest @docusaurus/types@latest'
alias version='npx docusaurus -V'

echo -e "🚀 Welcome to your Docusaurus Dev Container!"
echo -e ""
echo -e "📚 Quick Commands Reference:"
echo -e ""
echo -e "  📦 \e[33mversion\e[0m      Show current Docusaurus version."
echo -e "  ⬆️  \e[33mupgrade\e[0m      Upgrade Docusaurus core and plugins."
echo -e ""

EOF
fi
```

**.devcontainer/compose.yaml**

```yaml
# cspell:ignore erikvl87,langtool,ngrams

services:
  docusaurus:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - APP_HOME=${APP_HOME:-/opt/docusaurus}
        - OS_USERID=${OS_USERID:-1000}
        - OS_GROUPID=${OS_GROUPID:-1000}
    user: ${OS_USERID:-1000}:${OS_GROUPID:-1000}
    env_file:
      - .env
    volumes:
      - ..:${APP_HOME:-/opt/docusaurus}
      - node_modules:/opt/docusaurus/node_modules

    command: sleep infinity

  languagetool:
    image: erikvl87/languagetool
    restart: always
    environment:
      - langtool_languageModel=/ngrams
      - Java_Xms=512m
      - Java_Xmx=1g
    volumes:
      - ngrams:/ngrams

volumes:
  ngrams:
  node_modules:
```

**.devcontainer/devcontainer.json**

```json
{
  "name": "MyBlog",
  "service": "docusaurus",
  "dockerComposeFile": ["../.devcontainer/compose.yaml"],
  "workspaceFolder": "/opt/docusaurus",
  "mounts": [
    "source=../.devcontainer/history/.bash_history,target=/home/node/.bash_history,type=bind",
    "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind",
    "source=node_modules,target=/opt/docusaurus/node_modules,type=volume",
    "source=vscode-extensions,target=/home/node/.vscode-server/extensions,type=volume",
    "source=yarn-cache,target=/home/node/.cache/yarn/v6,type=volume"
  ],
  "portsAttributes": {
    "3000": {
      "onAutoForward": "openBrowser",
      "label": "Docusaurus Blog"
    },
    "8010": {
      "label": "LanguageTool"
    }
  },
  "forwardPorts": [3000, 8010],
  "containerEnv": {
    "OS_USERNAME": "node",
    "YARN_CACHE_FOLDER": "/home/node/.cache/yarn/v6"
  },
  "initializeCommand": "mkdir -p .docusaurus .devcontainer/history && touch .devcontainer/history/.bash_history && echo '.bash_history' > .devcontainer/history/.gitignore",
  "postCreateCommand": "sudo chown -R node:node /opt/docusaurus/node_modules && /opt/docusaurus/.devcontainer/bootstrap.sh",
  "remoteUser": "node",
  "containerUser": "${OS_USERID}:${OS_GROUPID}",
  "customizations": {
    "vscode": {
      "settings": {
        "[markdown]": {
          "editor.codeActionsOnSave": {
            "source.fixAll": "always"
          },
          "editor.defaultFormatter": "DavidAnson.vscode-markdownlint",
          "editor.formatOnSave": true,
          "editor.wordWrap": "wordWrapColumn",
          "editor.wordWrapColumn": 80,
          "editor.wrappingIndent": "indent"
        },
        "cspell.language": "en",
        "editor.bracketPairColorization.enabled": true,
        "editor.codeActionsOnSave": {
          "source.fixAll.markdownlint": "always"
        },
        "editor.folding": true,
        "editor.foldingStrategy": "auto",
        "editor.formatOnSave": true,
        "editor.guides.bracketPairs": "active",
        "editor.guides.bracketPairsHorizontal": "active",
        "editor.guides.highlightActiveBracketPair": true,
        "editor.guides.indentation": true,
        "editor.renderWhitespace": "all",
        "editor.rulers": [120, 70],
        "editor.stickyScroll.enabled": true,
        "editor.tabCompletion": "on",
        "files.associations": {
          ".mdx": "Markdown"
        },
        "files.autoSave": "onFocusChange",
        "files.eol": "\n",
        "files.defaultLanguage": "markdown",
        "files.insertFinalNewline": true,
        "files.trimTrailingWhitespace": true,
        "languageToolLinter.languageTool.motherTongue": "en",
        "languageToolLinter.languageTool.language": "en",
        "languageToolLinter.external.url": "http://languagetool:8010",
        "markdownlint.configFile": ".config/.markdownlint.json",
        "markdownlint.fixOnSave": true,
        "sonarlint.disableTelemetry": true,
        "telemetry.telemetryLevel": "off",
        "terminal.integrated.profiles.linux": {
          "bash": {
            "path": "/bin/bash",
            "icon": "terminal-bash"
          }
        },
        "terminal.integrated.defaultProfile.linux": "bash",
        "window.title": "${dirty}${rootName} ${separator} [DevContainer]",
        "workbench.colorTheme": "Visual Studio Light",
        "workbench.startupEditor": "terminal"
      },
      "extensions": [
        "DavidAnson.vscode-markdownlint",
        "davidlday.languagetool-linter",
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "GitHub.vscode-github-actions",
        "ms-vscode.makefile-tools",
        "shd101wyy.markdown-preview-enhanced",
        "streetsidesoftware.code-spell-checker",
        "TomasDahlqvist.markdown-admonitions",
        "usernamehw.errorlens",
        "yzhang.markdown-all-in-one"
      ]
    }
  }
}
```

**.devcontainer/Dockerfile**

```docker
# syntax=docker/dockerfile:1.6

# Use Dockerfile syntax version 1.6 for advanced features like cache mounts
# https://docs.docker.com/build/dockerfile/frontend/
# This Dockerfile is designed for use in a Devcontainer setup with VSCode

# Define build-time arguments for user and workspace configuration
ARG OS_USERID=1002
ARG OS_GROUPID=1002
ARG OS_USERNAME="node"
ARG APP_HOME="/opt/docusaurus"

# Use a custom base image that includes Node.js and Docusaurus dependencies
# This image is built from the `blog-docusaurus` Dockerfile file you'll
# find in the root directory of this blog
FROM blog-docusaurus:latest

# Re-declare APP_HOME to ensure it's available in later stages
ARG APP_HOME

# Switch to root to install system packages and configure environment
USER root

# Install essential packages with caching to speed up rebuilds
RUN --mount=type=cache,target=/var/lib/apt/lists \
    --mount=type=cache,target=/var/cache/apt \
    set -eux && \
    apt-get update && \
    apt-get install -y --no-install-recommends \
        git \
        bash \
        bash-completion \
        curl \
        ca-certificates \
        sudo && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    git config --global --add safe.directory "${APP_HOME}"  # Avoid Git safety warnings

# Re-declare user-related build arguments
ARG OS_USERID
ARG OS_GROUPID
ARG OS_USERNAME

# Grant passwordless sudo access to the node user
# Allow our node user to run "sudo xxx" in case of very specific use case
RUN set -eux && \
    echo "${OS_USERNAME} ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/"${OS_USERNAME}" && \
    chmod 0440 /etc/sudoers.d/"${OS_USERNAME}"

# Create and set permissions for VSCode server folders used for extensions and remote features
RUN set -eux && \
    mkdir -p "/home/${OS_USERNAME}/.vscode-server/bin" && \
    mkdir -p "/home/${OS_USERNAME}/.vscode-server/extensions" && \
    mkdir -p "/home/${OS_USERNAME}/.vscode-server/data" && \
    chown -R "${OS_USERNAME}:${OS_USERNAME}" "/home/${OS_USERNAME}/.vscode-server"

# ---------- Shell and history setup ----------
# Configure bash history behavior for the node user
RUN echo "export HISTFILE=/home/${OS_USERNAME}/.bash_history" >> "/home/${OS_USERNAME}/.bashrc" && \
    echo 'export HISTSIZE=10000' >> "/home/${OS_USERNAME}/.bashrc" && \
    echo 'export HISTFILESIZE=10000' >> "/home/${OS_USERNAME}/.bashrc" && \
    echo 'shopt -s histappend' >> "/home/${OS_USERNAME}/.bashrc"

# Customize the shell prompt for clarity and style
RUN set -eux && \
    echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> "/home/${OS_USERNAME}/.bashrc"

# ---------- Yarn setup and dependency caching ----------
# Define Yarn cache location to improve install performance
ENV YARN_CACHE_FOLDER="/home/${OS_USERNAME}/.cache/yarn/v6"

# Create the cache folder and assign correct ownership
RUN set -eux && \
    mkdir -p "${YARN_CACHE_FOLDER}" && \
    chown -R "${OS_USERNAME}:${OS_USERNAME}" "${YARN_CACHE_FOLDER}"

# Install project dependencies and the Sharp image plugin required by Docusaurus
RUN --mount=type=cache,target=${YARN_CACHE_FOLDER},uid=${OS_USERID},gid=${OS_GROUPID} \
    set -eux && \
    yarn install --immutable --frozen-lockfile --prefer-offline && \
    yarn add --platform=linux --arch=x64 sharp

# Switch to the node user for development tasks
USER "${OS_USERNAME}"

# Set the working directory to the application root
WORKDIR "${APP_HOME}"
```

> **Make sure to use your own UID/GID:**
>
> Please edit the `.devcontainer/.env` file and make sure these values are the ones you're using: please run `id -u` in your console and check if you get `1000`. If not, please report the obtained figure (f.i. `1002`) as `OS_USERID` and do the same with  the command `id -g`, here, it's for the `OS_GROUPID` variable.

Since we have two bash scripts, we need to make them executable. Please run this command in a terminal:

```bash
$ chmod +x .devcontainer/bootstrap.sh .devcontainer/bash_helpers.sh
```

Now we have to open the devcontainer so please run:

```bash
$ make build && make devcontainer
```

Once in VSCode, please press `F1` to open the **Command Palette** and run **Dev Containers: Rebuild Without Cache and Reopen in Container**.

> **Don't have it?:**
>
> If you don't have this command, please make sure to install the VSCode [Dev Container from Microsoft](https://marketplace.visualstudio.com/publishers/Microsoft).

![Devcontainer](./images/devcontainer_rebuild.webp)

Please click and thus run the **Dev Containers: Rebuild and Reopen in Container** action. If you look at the terminal (from inside VSCode), you'll get a lot of messages like the ones below:

![Building the devcontainer](./images/building_devcontainer.webp)

At some point in time, you'll see this:

![Yarn run](./images/yarn_run.webp)

It means that Docusaurus is ready to serve your content.

Please surf to `https://localhost:3000` and tadaaa.

> Screenshot — https\://localhost:3000
>
> ![The site is running](./images/site_is_running.webp)

Let's play. Open the `blog` folder and create a new file called `index.md`:

![Creating a new blog post](./images/new_blog_post.webp)

Save it and, oh?, your browser will immediately reflect the change (hot reload is indeed enabled):

> Screenshot — https\://localhost/blog/index/
>
> ![The new blog post is already running](./images/new_blog_post_running.webp)

### Running the terminal from the devcontainer

Something really cool too is the use of the **Terminal** still inside VSCode.

In case of need, press `CTRL`+`ù` to open the Terminal and see the `+` button top right in that window. Click on the `+` to create a new one:

![Using the terminal in the devcontainer](./images/devcontainer_terminal.webp)

Did you see? You get a welcome screen with a few actions you can run. For instance, to upgrade Docusaurus to the latest version, you just need to run `upgrade` and that's all.

Could we make easier than this?

> **From where comes this welcome screen?:**
>
> See the `.devcontainer/bash_helpers.sh` file you've previously copied/pasted.
>
> You'll in the Bash script the design of the welcome screen and also a few aliases like `version` to get the version of the installed Docusaurus instance.

## What are the advantages of such approach?

There are so numerous... Let's start with this one: on my host, I've just Docker and that's all. I don't need to install `Node` as needed by Docusaurus neither any Linux dependencies.

I can also build my blog as a standalone image. Just look at my `cavo789/blog` Docker image on <https://hub.docker.com/r/cavo789/blog>. If you want to run it on your own machine, you just need to run `docker pull cavo789/blog && docker run -d -p 443:443 --name blog cavo789/blog` then surf on `https://localhost` and that's all.

Using a Devcontainer like illustrated in this article, you can also fine-tune your environment i.e. tell which extension in VSCode has to be installed and how that extension has to be configured. You can also define the look\&feel of the editor (the font, size, color, template, ...).

Since everything is isolated, you can *drop* the container and recreate it without pain.

Working with a team? Devcontainer is then the way-to-go since everyone will have the exact same environment.

## Using LanguageTool in VSCode

Let's see one of the many advantages: instead of *just using VSCode to write our posts*, let's use LanguageTool which provides basic grammar and spellchecking for your posts.

And you know what? You already have it. See your `.devcontainer/compose.yaml` file. You'll see a service called `languagetool` based on a Docker image called **erikvl87/languagetool**. And now, see your `.devcontainer/devcontainer.json` file. Search for **languageToolLinter** and you'll see a local URL (based on an IP); this is the one exposed by the service. And look further for the **davidlday.languagetool-linter** extension, this is the one that'll make the magic happen.

Here's an example of how LanguageTool works:

![LanguageTool in action](./images/language_tool_in_action.webp)

For sure, the correct sentence should be *This example illustrates LanguageTool in action*.

## Code Spell Checker

Here is another example, a mistake a French-speaking person can make inadvertently:

![The word example is misspelled](./images/example_is_misspelled.webp)

Here, the extension used is *Code Spell Checker* from **streetsidesoftware**.

## Markdown lint

And perhaps, while you write your Markdown content, VSCode will show you errors displayed in orange like `MD047/single-training-newline` to tell you that you forgot to add an empty line at the very bottom of the file.

These warnings come from Markdownlint which is another extension already installed in the devcontainer.
