# Running Docusaurus using Docker A series of 3 article(s), in reading order. # Running Docusaurus with Docker > Run Docusaurus with Docker! Follow this easy tutorial to create your Docusaurus Docker image, use docker compose, and get your documentation or blog running fast. --- ![Running Docusaurus with Docker](/img/v2/docusaurus_tips.webp) **TL;DR** This guide explains how to run Docusaurus using Docker to avoid local Node.js dependencies. You'll learn to create a custom Dockerfile, set up a `compose.yaml` for development with hot-reloading, and manage your blog content within a containerized environment. As you know, this blog is powered by [Docusaurus](https://docusaurus.io/). I'm writing blog posts in Markdown files (one post = one `.md` file) and Docusaurus will convert them into HTML pages. In this first article, we're going to learn how to install Docusaurus... ouch, sorry, not install Docusaurus as we're going to use Docker to simplify our lives. ## Three files, one command Three files in an empty folder — a `Dockerfile`, a `.dockerignore` and a `compose.yaml` — then: ```bash $ docker compose up --detach --build ``` A few minutes later (only the first time), `http://localhost:`3000 answers: > Screenshot — http\://localhost:3000 > > ![Docusaurus homepage](./images/homepage.webp) And the `Blog` menu lists the Markdown files sitting in the `blog` folder of your own machine: > Screenshot — http\://localhost:3000/blog > > ![Our posts](./images/posts.webp) No Node.js installed, no `npm`, no `yarn`, no version conflict with whatever else you're working on. ## Why it works * **The image builds the Docusaurus skeleton at build time**, so the site engine lives inside the container and never touches your machine. * **Your `blog` folder is mounted from the host**, not copied: the container reads the very files you edit in your editor. * **Hot reload follows your files**, so saving a Markdown file in VS Code refreshes the page in your browser — the container is running `yarn start`, not a static build. ## Create your own Docusaurus image Create a temporary directory by running `mkdir /tmp/docusaurus && cd $_`. This done, start your preferred editor and open the folder. On my side, I'm using Visual Studio Code so I'll just run `code .` in my Linux console. ### Create a Dockerfile file In your project directory (so `/tmp/docusaurus`), create a file called `Dockerfile` with this content: **/tmp/docusaurus/Dockerfile** ```docker FROM node:22-alpine RUN npx create-docusaurus@latest /app classic --javascript && \ chown -R node:node /app USER node WORKDIR /app RUN cd /app && yarn install COPY . . CMD ["yarn", "start", "--host", "0.0.0.0"] ``` Seven lines, and each one earns its place — if you want them explained one by one, there's a dedicated section at the end of this article. ### Create a .dockerignore file We've seen here above the `COPY . .` command which instructs to copy everything from our project's directory from our local computer to the Docker image but, in fact, no, we don't need to copy everything. In the Docker image, we don't need to have such folders like `build` or `node_modules` neither files like `.gitignore` or some others. We don't need them because they will be created in Docker (`node_modules` is created using the `yarn install` command so no need to lose time to copy that folder). But, also, we don't need to copy folders like `blog`, `pages`, `static`, ... since these folders have to stay on our computer and just *synchronized* with the running Docker container (so no need to put them in the image at this stage). So, please create a `.dockerignore` file with this content: **.dockerignore** ``` build/ node_modules/ .dockerignore .gitignore .markdownlint_ignore .markdownlint.json *.log compose.yaml Dockerfile LICENSE makefile README.md blog/ pages/ static/ ``` ### Create a compose.yaml file The third file to create is `compose.yaml` with this content: **compose.yaml** ```yaml name: my_docusaurus_blog services: blog: build: . ports: - 3000:3000 user: 1000:1000 volumes: - ./blog:/app/blog ``` As you can see, we need to have a folder called `blog` on our machine and we'll synchronize that folder inside the Docker container. Our `blog` folder will be *mounted* in folder `/app/blog` in the container (if the concept of volumes is new to you, read [Share data between your running Docker container and your computer](/blog/docker-volume)). ### Create simple blog items Just to have a bit of content, let's create a few articles automatically. Please run the following commands to create three Markdown files (our posts) in the `blog` folder: ```bash $ ( mkdir -p blog && cd $_ echo '---' > 2024-02-04-welcome-world.md echo 'title: Hello World!' >> 2024-02-04-welcome-world.md echo '---' >> 2024-02-04-welcome-world.md echo 'Hello world! Proud to be here!!!' >> 2024-02-04-welcome-world.md echo '---' > 2024-02-05-my-first-post.md echo 'title: My first blog post' >> 2024-02-05-my-first-post.md echo '---' >> 2024-02-05-my-first-post.md echo 'My first blog post' >> 2024-02-05-my-first-post.md echo '' >> 2024-02-05-my-first-post.md echo '![Unsplash random](https://source.unsplash.com/random?dinosaure)' >> 2024-02-05-my-first-post.md echo '---' > 2024-02-06-my-second-post.md echo 'title: My second blog post' >> 2024-02-06-my-second-post.md echo '---' >> 2024-02-06-my-second-post.md echo 'My second blog post' >> 2024-02-06-my-second-post.md echo '' >> 2024-02-06-my-second-post.md echo '![Unsplash random](https://source.unsplash.com/random?dinosaure)' >> 2024-02-06-my-second-post.md ) ``` So if you take the time to look at what we've now in our `/tmp/docusaurus` folder, here is the list of files / folders: ```tree expanded=true showJSX=false debug=false title="/tmp/docusaurus" . ├── .dockerignore ├── Dockerfile ├── blog │   ├── 2024-02-04-welcome-world.md │   ├── 2024-02-05-my-first-post.md │   └── 2024-02-06-my-second-post.md └── compose.yaml ``` > **`tree` is not part of core Linux installation:** > > Just in case you're interested by the `tree` utility and don't have yet, simply run `sudo apt-get update && sudo apt-get install tree` to install it. This step is optional. ### Run Docusaurus At this stage of the tutorial, you've all required files and a few blog posts so, let's start everything with the `docker compose up --detach --build` command shown at the beginning of this article. Your blog is then accessible on your computer here: `http://localhost:`3000. > **Which port number to use?:** > > The port number is the one you've mentioned in the `compose.yaml` file in line 3000`:3000`. > > If you wish another port like `3002` f.i., just edit the yaml file and replace `3000 > :3000` with `3002:3000` and rerun the `docker compose up --detach` command. > **Chronology of blog posts:** > > Clicking on the `Blog` menu, you'll notice the default ordering follows chronological order: the last blog item we've created (`2024-02-06-my-second-post.md`) is the first one in the list. For this tutorial, images come from `unsplash.com` but with a theme: dinosaurs. *If you refresh the page, you'll get new images.* ### Improved look & feel We'll create a file on our computer, in our `blog` folder. I'm using VS Code so I jump into my editor, go to the `blog` folder and create a new file, let's say: `2024-02-07-really-better.md` ![Really better one](./images/vscode.webp) For ease of use, go to this tutorial: . Copy/paste the `2019-09-05-hello-docusaurus.md` example to your `2024-02-07-really-better.md` file. > Screenshot — http\://localhost:3000/blog/2019-09-05-hello-docusaurus.md > > ![Our new article](./images/vscode-article.webp) Just go back to your browser and refresh the page (press `F5`). Your new post is there! > Screenshot — http\://localhost:3000/blog/2024-02-07-really-better.md > > ![Your new post is there](./images/with-new-post.webp) ## Under the hood — the Dockerfile, line by line (skip this if you just want it running) * Line 1: we'll use Node.js v22 LTS in its alpine version, * Line 2: the `RUN npx create-docusaurus@latest /app classic && chown -R node:node /app` command will install the latest version of Docusaurus (in the `/app` folder) and make sure the folder is owned by our `node` user, * Line 3: from now, we'll do everything using the `node` user, * Line 4: `/app` will be the default working directory in the image, * Line 5: the `cd /app && yarn install` command will jump in the folder and will install node dependencies, * Line 6: `COPY . .` will copy everything from your project's directory (on your host) into the Docker image (in folder `/app` since that one is the default working directory) and * Line 7: the command `CMD ["yarn", "start", "--host", "0.0.0.0"]` will run `yarn start --host 0.0.0.0` which is the instruction to run Docusaurus, make the *transparent* conversion from Markdown pages to HTML and will render the website on the default port (which is port `3000`). ## Stop and restart Since the `blog` folder is stored on your computer, we can stop the blog and start it again without losing anything. To illustrate this, we can run `docker compose stop && docker compose rm --force` to stop and kill the container. Then run `docker compose up --detach --build` to rebuild it. By surfing back to `http://localhost : 3000`, as you'll see, you've still your blog with your latest changes. Nothing was lost. ## What's next? The setup above is a *development* one: your content stays on your host and is mounted in the container. Two follow-up articles go further: * [Running your own blog with Docusaurus and Docker](/blog/docusaurus-docker-own-blog) — turning this playground into your real blog and * [Encapsulate an entire Docusaurus site in a Docker image](/blog/docker-docusaurus-prod) — building a self-contained image ready to be deployed. --- # Encapsulate an entire Docusaurus site in a Docker image > Deploy your Docusaurus documentation site in production with Docker. Follow this step-by-step guide on creating a highly efficient, multi-stage Docker image. --- ![Encapsulate an entire Docusaurus site in a Docker image](/img/v2/docusaurus_using_docker.webp) **TL;DR** This article (now deprecated in favor of a newer one) shows how to package an entire Docusaurus site into a single, self-contained Docker image using a three-stage `Dockerfile`: a Node base stage, a build stage that runs `yarn build`, and a final `nginx` stage that only ships the static output — shrinking the image from \~740MB down to \~87MB. It covers building, running, entering an interactive shell, and pushing the image to Docker Hub. > **This article is deprecated:** > > This article has been recently reviewed, please read [Running Docusaurus using Docker](/blog/running-docusaurus-with-docker) instead. Started on 2 November 2023, this article is already the hundredth I've published on this blog. To mark the occasion, I wanted to do something a bit special to honor the blog. What better way than to build a **100% Docker version**, i.e. in the form of a downloadable Docker image that **you can run with a single command line**. A single command to download the blog and run it on your machine (Linux, Mac or Windows) and have a fully working site; nice isn't it? And as usual, you'll find all the information you need below so that you can do the same for your own Docusaurus installation. > **Don't wait more:** > > Start a console, run `docker pull cavo789/blog && docker run -d -p 80:80 --name blog cavo789/blog` to download a local copy of my blog and to start it. Once done, just open `http://localhost` on your computer and ... congratulations, you've just obtained an offline yet executable version! Now, we'll learn to do the same for your own Docusaurus instance. Ladies and gentlemen, please follow the guide... ## Result That single `docker run` command from the AlertBox above gets you this, running entirely offline: > Screenshot — http\://localhost > > ![Homepage of your running Docusaurus instance](./images/homepage.webp) > Screenshot — http\://localhost/blog > > ![Our blog](./images/blog.webp) The rest of this article builds the image that produces this — a multi-stage `Dockerfile` that turns any Docusaurus site into a single, self-contained \~87MB image. ## Create a dummy blog if needed *If you already have a Docusaurus installation on your computer, just skip this chapter. If you don't, [Running Docusaurus with Docker](/blog/docusaurus-docker) covers the development setup that comes before this one.* In this part, I'll suppose you don't have a Docusaurus installation yet, so we'll create a dummy site with a very few, basic, blog posts. Jump in a console and run the following commands: `mkdir /tmp/docusaurus && cd $_`. You're now in the `/tmp/docusaurus` folder. Copy/paste in the console instructions below, with the parentheses included. This will create a subfolder called `blog` with three markdown files in it, our three fake articles. ```bash ( $ mkdir -p blog && cd $_ $ echo '---' > 2024-04-26-welcome-world.md $ echo 'title: Hello World!' >> 2024-04-26-welcome-world.md $ echo '---' >> 2024-04-26-welcome-world.md $ echo 'Hello world! Proud to be here!!!' >> 2024-04-26-welcome-world.md $ echo '---' > 2024-04-27-my-first-post.md $ echo 'title: My first blog post' >> 2024-04-27-my-first-post.md $ echo '---' >> 2024-04-27-my-first-post.md $ echo 'My first blog post' >> 2024-04-27-my-first-post.md $ echo '' >> 2024-04-27-my-first-post.md $ echo '![Unsplash random](https://source.unsplash.com/random?dinosaure)' >> 2024-04-27-my-first-post.md $ echo '---' > 2024-04-28-my-second-post.md $ echo 'title: My second blog post' >> 2024-04-28-my-second-post.md $ echo '---' >> 2024-04-28-my-second-post.md $ echo 'My second blog post' >> 2024-04-28-my-second-post.md $ echo '' >> 2024-04-28-my-second-post.md $ echo '![Unsplash random](https://source.unsplash.com/random?dinosaure)' >> 2024-04-28-my-second-post.md ) ``` So, now, we've a dummy blog. ## Prepare our Docusaurus installation for Docker *If you've skipped the previous folder because you already had a Docusaurus instance, jump in it (`cd `).* We'll need to create some files for Docker. Start your preferred editor and open the folder; on my side, I'm using Visual Studio Code so I'll just run `code .` in my Linux console. We need to create a few files... ### Create a Dockerfile file Please create a file called `Dockerfile` with this content: **Dockerfile** ```docker # syntax=docker/dockerfile:1 # Inspired by https://docusaurus.community/knowledge/deployment/docker/?package-managers=yarn # Stage 1: Base image. FROM node:22-alpine AS base # Disable color output from yarn to make logs easier to read. ENV FORCE_COLOR=0 # Enable corepack. RUN corepack enable # -------------------------------------------------------------------- # Stage 2: Production build mode. FROM base AS building_production # Install the latest version of Docusaurus RUN npx create-docusaurus@latest /opt/docusaurus/ classic --javascript \ # Uncomment the line below to remove dummy files like dummy blog, docs, src, ... # && rm -rf /opt/docusaurus/blog /opt/docusaurus/docs /opt/docusaurus/src\ && chown -R node:node /opt/docusaurus/ # Set the working directory to `/opt/docusaurus`. WORKDIR /opt/docusaurus # We need our package.json / yarn.lock file before running yarn install COPY package.json yarn.lock ./ # Install dependencies with `--immutable` to ensure reproducibility. RUN yarn install --immutable # Copy over the source code. COPY . /opt/docusaurus/ # Build the static site (generated files will be created in /opt/docusaurus/build) RUN yarn build # -------------------------------------------------------------------- # Stage 3: Serve with nginx FROM nginx:stable-alpine3.19-perl AS production # Copy the Docusaurus build output. COPY --from=building_production /opt/docusaurus/build /usr/share/nginx/html WORKDIR /usr/share/nginx/html ``` This file is a **multi-stage** Dockerfile. The main objectives are to have an improved cache layer system and a smaller, in size, final image. A multi-stage file is also really useful to be able to build more than one image like a development or a production one. As you can see, we are using three stages (a stage starts with the `FROM` clause). Our goal in this article is to create a final Docker image with a static version of our Docusaurus installation. **We wish to have an image containing a web server and our site.** Before being able to do this, we need to: 1. Use a `node` image since Docusaurus is a `NodeJS` application. We'll use `node:21-alpine` as base image. 2. Then we need to install `docusaurus` and start `yarn` to install dependencies. This done, we'll copy all files from our current folder in the Docker image. Once copied, we need to build our site and convert markdown files to HTML pages and, finally, 3. We need a web server like `Apache`, `Caddy` or `nginx` *(`Caddy` will give the smallest image in size but still quite difficult to configure SSL using Docker)*. This is our three stages. **The stage 1 is called `base`**. In that stage, we do almost nothing, just download a `node` alpine version and initialize some variables. This step can, in the future, also be used by a `development` stage but, in this article, let's concentrate on the `production` one. **Dockerfile** ```docker FROM node:21-alpine AS base # Disable color output from yarn to make logs easier to read. ENV FORCE_COLOR=0 # Enable corepack. RUN corepack enable ``` **In stage 2, called `building_production`**, we're extending the `base` stage. As you can see below, we'll install Docusaurus and its dependencies. Then we'll copy files from our current folder into the image in the process of being created and, finally, we'll generate (build) static web pages (HTML, CSS, JavaScript and images). At the end of this stage, we'll obtain a static version of our Docusaurus site in the folder `/opt/docusaurus/build`. **Dockerfile** ```docker FROM base AS building_production # Install the latest version of Docusaurus RUN npx create-docusaurus@latest /opt/docusaurus/ classic --javascript \ # Uncomment the line below to remove dummy files like dummy blog, docs, src ... # && rm -rf /opt/docusaurus/blog /opt/docusaurus/docs /opt/docusaurus/src \ && chown -R node:node /opt/docusaurus/ # Set the working directory to `/opt/docusaurus`. WORKDIR /opt/docusaurus # We need our package.json / yarn.lock file before running yarn install COPY package.json yarn.lock ./ # Install dependencies with `--immutable` to ensure reproducibility. RUN yarn install --immutable # Copy over the source code. COPY . /opt/docusaurus/ # Build the static site (generated files will be created in /opt/docusaurus/build) RUN yarn build ``` What is important to note here is our working directory: `/opt/docusaurus/`. Our static site has been copied into that folder so the result of the last instruction of the second stage (`yarn build`) will thus create a subfolder `build` in `/opt/docusaurus/`. At the end of this stage, we've our static website but not yet a web server. **In stage 3**, we will use [nginx](https://hub.docker.com/_/nginx). **Dockerfile** ```docker FROM nginx:stable-alpine3.19-perl AS production # Copy the Docusaurus build output. COPY --from=building_production /opt/docusaurus/build /usr/share/nginx/html WORKDIR /usr/share/nginx/html ``` As you can see, this stage is very basic. We just use nginx, copy the static website created earlier into its default web folder and it's done. > **Why a multi-stage image is better than a `monolithic` stage?:** > > Take a look at the last stage, our web server. We're just using the `nginx` web server and to make it working, we need to copy our `build` folder where Docusaurus has put its static files (html, css, js and images). > > Since we're only recovering the `build` folder from the previous stage, the final image will no longer contain `NodeJs`, `Yarn`, `Docusaurus` or anything else previously installed. Nor will we have any temporary files that may have been installed; we don't need the original version of our blog, i.e. our original markdown files. > > We'll use the `nginx` image and in that image we'll just copy files we need from the previous stage. Doing so, our final image will be smaller in size and will just contain what we need to run the final application, here our Docusaurus static site. > > The image size of the `building_production` stage was 740MB and the one of the `nginx` stage is just 87MB. **Around 9 times lower!!!** > **See my own Dockerfile:** > > I'm using, for my own blog, a development stage. Take a look at my [Dockerfile](https://github.com/cavo789/blog/blob/main/Dockerfile) to see how I do. Make sure to also take a look at my [makefile](https://github.com/cavo789/blog/blob/main/makefile) and the different `docker-compose-xxx.yml` files in my project. ### Create a .dockerignore file The second file we need to create should be called `.dockerignore` and with this content: **.dockerignore** ``` build/ node_modules/ .git/ .dockerignore .gitignore .markdownlint_ignore .markdownlint.json *.log compose.yaml Dockerfile LICENSE makefile README.md ``` The `.dockerignore` file is there to ask Docker not to copy everything in your final image when running the instruction `COPY . /opt/docusaurus/` present in the `Dockerfile`. Indeed, in our final image we don't need f.i. folders like `build/` or `node_modules/` because they'll be created while building the image. We don't need our `.git/` folder (if present) either, we don't need temporary or files needed just for the build. The `.dockerignore` file is then needed to keep the image smaller in size and to prevent to copy sensitive files (those containing secrets or configuration items) in the final image. ## Small summary If you've followed the creation of the temporary folder as described here above, you should have the following situation: two files in the `/tmp/docusaurus` folder and three Markdown files in the `blog` subdirectory. ```bash $ ls -alhR drwxr-xr-x 3 root root 4.0K Apr 28 11:02 . drwxrwxrwt 25 root root 12K Apr 28 10:44 .. -rw-r--r-- 1 root root 155 Apr 27 09:47 .dockerignore -rw-r--r-- 1 root root 1.2K Apr 28 10:45 Dockerfile drwxr-xr-x 2 root root 4.0K Apr 28 11:02 blog . $ ls -alh ./blog: . drwxr-xr-x 2 root root 4.0K Apr 28 11:02 . drwxr-xr-x 3 root root 4.0K Apr 28 11:02 .. -rw-r--r-- 1 root root 61 Apr 28 11:02 2024-04-26-welcome-world.md -rw-r--r-- 1 root root 119 Apr 28 11:02 2024-04-27-my-first-post.md -rw-r--r-- 1 root root 121 Apr 28 11:02 2024-04-28-my-second-post.md ``` ## Build our Docker image Our objective was to create a Docker image containing our Docusaurus site. When creating a Docker image, we should give it a name. > **The name of the image should respect the `owner/name` pattern.:** > > `owner` has to be your username on Docker Hub. In my case, my username there is `[cavo789](https://hub.docker.com/u/cavo789)` so if I wish to publish an image, I should use `cavo789` for the first part. Then, I need to specify a unique name not yet present in my profile. > > In my case `cavo789/blog` is then a good choice. For this article, I'll use `johndoe/blog` since I'll not publish that image on the Internet. Still in your console, please run the following command: ```bash $ docker build --tag johndoe/blog --target production . ``` The final `.` in the instruction above means *current folder*; `/tmp/docusaurus` in my case. > **Caution:** > > Since our `Dockerfile` is a multi-stage one, we need to specify which stage we wish. This is done by using the `--target` CLI flag. > > If you look at our `Dockerfile` file we've created earlier, our three stages are called `base`, `building_production` and `production`. To build the image with the web server, you need to specify `production` for the target but if you're interested in the generated files, not the web server, you can run `docker build --tag johndoe/blog --target building_production .`. The `docker build` command will take one or two minutes depending on the speed of your network connection and computer. Once successfully fired, you'll then have a new Docker image on your computer. You can retrieve it by running `docker image list` to get the list of local images. > **Information:** > > By running `docker image list | grep -i blog`, you can retrieve the image and its size. It's 87MB for me on this moment for the dummy blog created in this blog post. > > By running `docker build --tag johndoe/blog --target building_production .` (without `nginx` thus but with `Node`) the size will be 740MB. As you can see, we've divided the size by, almost, 9. ## And use it Now that our image is created, we can do a few things like just running it and get an offline version of our Docusaurus site or play with a container and start an interactive shell session or, of course, publish it on Docker hub. ### Run the image i.e. create a container and run the site To do this, just run the following command: ```bash $ docker run -d --publish 80:80 --name blog johndoe/blog ``` Very quickly, you'll get a very long ID as result like f.i. `cae6989bee2a2339a4c0116be2b86ee3dae0b46d47a6c53dcb6e50098726c0b1`. Just ignore this at this moment, it just means your container has been created successfully. Now, start your browser and surf to `http://localhost : 80` — that's the homepage and blog shown at the top of this article. As mentioned, the site will be running on your computer i.e. offline. You can disconnect from the Internet, everything is running in RAM; on your machine. If you're curious, just run `docker ps` (or `docker container list` which is a synonym) to see the list of containers. You'll see yours. By running `docker container stop blog` (blog is the name we've defined in the `docker run` used above in this chapter); we can stop the blog. Return to your browser, surf on `http://localhost : 80` and you'll see, the site is no more active. Run `docker container start blog` to reactivate it. ### Start an interactive shell session If you want to start an interactive shell in your image, just run `docker run -it johndoe/blog /bin/sh`. You'll then be *inside* the container and you'll be able to inspect files f.i. Be careful to correctly understand the notion of container: your website is running in a container and you've just started a second one. To prove this, just run `rm -rf blog` to kill the blog in your shell container, go back to the browser, refresh the page: it's still working. Type `exit` to quit the shell and return to your console. Rerun `docker run -it johndoe/blog /bin/sh` and check if the removed `blog` folder is there or not: it's there. A container is something that is recreated every time so everything done in a container (when there is no mounted volumes like the one we use here) just stay in RAM. > **Use Docker Desktop - Containers if you want to interact with your site:** > > Purely informative: if you're using Windows, you can jump in *Docker Desktop*, open the list of containers, click on your running one (the blog) then click on the *Exec* tab to start an interactive shell in *that* container. If you remove f.i. the blog folder as illustrated below; then if you go back to the browser, yes, you've removed the blog and refreshing the site will conduct to a 404 error page. > > ![Docker Desktop](./images/docker_desktop.webp) > > Just remove the container and create it again to retrieve the blog. ### Push it on Docker Before being able to publish your image on Docker hub, first, you should make sure you've logged in. Run `docker login` on your command line and make a login with a valid account. *If you were already authenticated, you'll see `Authenticating with existing credentials...`* Now, push your image by running `docker image push` followed by your image name; in my case, it'll be `cavo789/blog` since I'm logged in as `cavo789`. ### Retrieve it from Docker Hub The circle is now complete... You can tell your friends and colleagues that they can now use your image by just running the command `` `docker pull && docker run -d -p ` 80 `:80 --name ` blog ` ` `` as I stated in the preface of this article. Enjoy! --- # 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\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 <> "$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 . 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. ---