Technology portal

MC Tech Solutions

Hardware, networking, software, repair, and technical guides

Astro + Directus Setup Guide with Docker

This guide documents a working setup for an Astro site using Directus as the CMS, including Docker setup, category page fixes, article page setup, relation handling, and article styling fixes.

Architecture

The setup uses two main parts: a self-hosted Directus CMS and an Astro frontend. Directus manages article and category content, while Astro renders category pages such as /category/software and individual article pages such as /articles/example-post.

For containerized development or deployment, Directus is commonly run with Docker Compose, and Astro can also be packaged into a Docker image for local or production use.

Step 1: Plan the Content Model

Before writing any frontend code, create the content structure in Directus. At minimum, this setup needs an articles collection and a categories collection linked by a relation field such as category.

A simple working structure is shown below.

Collection Important fields Purpose
categories name, slug Stores category labels such as Software.
articles title, slug, summary, content, status, date_created, date_updated, views, category Stores article content and links each article to a category.

Step 2: Run Directus in Docker

Directus documents self-hosting and notes that Docker Compose is a standard deployment method for running the service with persistent data and supporting services.

A practical starting point is a docker-compose.yml for Directus and a database. For a beginner setup, the following example is enough to understand the flow:

text
version: '3.8' services: database: image: postgres:16-alpine container_name: directus_db restart: unless-stopped environment: POSTGRES_DB: directus POSTGRES_USER: directus POSTGRES_PASSWORD: strongpassword volumes: - ./data/postgres:/var/lib/postgresql/data directus: image: directus/directus:latest container_name: directus_app restart: unless-stopped ports: - "8055:8055" depends_on: - database environment: KEY: replace-this-with-a-long-random-key SECRET: replace-this-with-a-long-random-secret DB_CLIENT: pg DB_HOST: database DB_PORT: 5432 DB_DATABASE: directus DB_USER: directus DB_PASSWORD: strongpassword ADMIN_EMAIL: admin@example.com ADMIN_PASSWORD: strongpassword WEBSOCKETS_ENABLED: "true" volumes: - ./uploads:/directus/uploads - ./extensions:/directus/extensions

Step 3: Start Directus

From the folder containing docker-compose.yml, start Directus with Docker Compose:

bash
docker compose up -d

Once the containers are running, Directus should be available on http://localhost:8055.

Step 4: Create Categories and Articles in Directus

After opening the Directus admin panel, create the two collections and their fields, then add a relation from articles.category to categories. This relation is important because the Astro pages later depend on being able to fetch nested category data from the article record.

Create a few categories first, such as Software, Networking, or Security, then create published articles that link to those categories.

Step 5: Create the Astro Project

Astro supports file-based routing, including dynamic route files such as [slug].astro, which are used later for both category and article pages.

A basic Astro project can be created with the normal Astro starter workflow, then configured to fetch data from Directus.

Step 6: Add the Directus Environment Variable

Store the Directus base URL in your Astro project so the pages can fetch content from the CMS. The site code used in this setup relies on import.meta.env.DIRECTUS_URL.

Example .env file:

text
DIRECTUS_URL=http://localhost:8055

If Astro is also running inside Docker, localhost from inside the Astro container will not point to the Directus container. In that case, use the Compose service name instead, such as http://directus:8055.

Step 7: Build the Category Page

The category route uses src/pages/category/[slug].astro. Astro requires dynamic routes to provide route parameters through getStaticPaths() when prerendering static pages.

The main fix in this setup was to fetch published articles with category.*, then filter in Astro using article.category?.slug === slug. This worked more reliably than trying to force a relational REST filter before the relation shape was fully confirmed.

A working pattern is shown below:

text
--- import MainLayout from "../../layouts/MainLayout.astro"; export async function getStaticPaths() { const baseUrl = import.meta.env.DIRECTUS_URL; const categoriesRes = await fetch(`${baseUrl}/items/categories?fields=slug`); const categoriesJson = await categoriesRes.json(); const categories = categoriesJson.data ?? []; return categories.map((category) => ({ params: { slug: category.slug } })); } const baseUrl = import.meta.env.DIRECTUS_URL; const { slug } = Astro.params; const [articlesRes, categoriesRes] = await Promise.all([ fetch(`${baseUrl}/items/articles?filter[status][_eq]=published&fields=title,summary,slug,date_created,date_updated,views,category.*&sort=-date_created`), fetch(`${baseUrl}/items/categories?fields=name,slug&sort=name`) ]); const articlesJson = await articlesRes.json(); const categoriesJson = await categoriesRes.json(); const allArticles = articlesJson.data ?? []; const categories = categoriesJson.data ?? []; const currentCategory = categories.find((category) => category.slug === slug); const articles = allArticles.filter((article) => article.category?.slug === slug); ---

Step 8: Why the Category Fix Works

Directus supports relational data access, but the route works most predictably when the page receives the expanded relation object instead of guessing the exact nested filter syntax for the current schema.

For a beginner-friendly project, filtering in Astro is easier to inspect and debug because the data shape is visible directly in the fetched article object.

Step 9: Build the Article Page

The article detail route uses src/pages/articles/[slug].astro, which creates one page per article slug. This is the standard Astro dynamic route pattern for content pages.

The page also needs getStaticPaths() so Astro can generate all article pages in static mode.

A working version looks like this:

text
--- import MainLayout from "../../layouts/MainLayout.astro"; export async function getStaticPaths() { const baseUrl = import.meta.env.DIRECTUS_URL; const articlesRes = await fetch( `${baseUrl}/items/articles?filter[status][_eq]=published&fields=slug` ); const articlesJson = await articlesRes.json(); const articles = articlesJson.data ?? []; return articles.map((article) => ({ params: { slug: article.slug } })); } const baseUrl = import.meta.env.DIRECTUS_URL; const { slug } = Astro.params; const [articleRes, categoriesRes] = await Promise.all([ fetch( `${baseUrl}/items/articles?filter[slug][_eq]=${slug}&filter[status][_eq]=published&fields=title,slug,summary,content,date_created,date_updated,views,category.*` ), fetch(`${baseUrl}/items/categories?fields=name,slug&sort=name`) ]); const articleJson = await articleRes.json(); const categoriesJson = await categoriesRes.json(); const article = articleJson.data?.[0] ?? null; const categories = categoriesJson.data ?? []; ---

Step 10: Render the Article Body

The article body is rendered with set:html={article.content}. This works well when Directus stores rich text as HTML and the frontend needs to render the resulting markup directly.

If the article body is blank, the likely cause is that the Directus field is not named content, or the field returns another structure such as markdown or JSON blocks instead of HTML.

Step 11: Fix Article Readability

After the route worked, the next issue was that the text blended into the background. This commonly happens when the site theme uses muted or dark colors and the article content inherits colors that are too low-contrast for body text.

Astro supports scoped styles, and the HTML rendered through set:html can be styled through a wrapper such as .article-content using :global(...) selectors.

A practical contrast fix is shown below:

text
<style> .article-page { max-width: 800px; margin: 0 auto; padding: 2rem 1rem 4rem; color: #f5f5f5; } .article-page h1, .article-content :global(h2), .article-content :global(h3), .article-content :global(h4) { color: #ffffff; } .article-meta { color: #c7c7c7; } .article-content, .article-content :global(p), .article-content :global(li), .article-content :global(td), .article-content :global(th) { color: #f0f0f0; } .article-content :global(a) { color: #7cc4ff; } .article-content :global(blockquote) { background: rgba(255, 255, 255, 0.06); color: #f3f3f3; } </style>

Step 12: Dockerize the Astro Site

Astro documents Docker deployment patterns and notes that a Node-based container requires the Node adapter when serving Astro directly from Node on port 4321.

A practical Dockerfile for Astro in server mode looks like this:

text
FROM node:20-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ FROM base AS prod-deps RUN npm install --omit=dev FROM base AS build-deps RUN npm install FROM build-deps AS build COPY . . RUN npm run build FROM base AS runtime COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist ENV HOST=0.0.0.0 ENV PORT=4321 EXPOSE 4321 CMD ["node", "./dist/server/entry.mjs"]

Astro’s Docker guidance also recommends exposing the correct container port and binding the app to 0.0.0.0 so it is reachable from outside the container.

Step 13: Add Astro Docker Ignore

To keep the image smaller and cleaner, add a .dockerignore file:

text
.DS_Store node_modules dist

Step 14: Astro Config for Docker Server Mode

Serving Astro from Node in Docker requires the Node adapter and a server-style output mode instead of plain static output.

A minimal example is:

js
import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }) });

Step 15: Build and Run the Astro Container

Build the Astro image from the project root:

bash
docker build -t english-site .

Run it locally and map a local port to the container port:

bash
docker run -p 4321:4321 --env DIRECTUS_URL=http://host.docker.internal:8055 english-site

If both Astro and Directus are running in the same Docker Compose stack, the DIRECTUS_URL should point to the Directus service name instead of host.docker.internal.

Step 16: Run Astro and Directus Together with Docker Compose

A combined Compose setup makes local development easier because both services can see each other by service name on the Docker network.

A simplified example structure is:

text
version: '3.8' services: database: image: postgres:16-alpine environment: POSTGRES_DB: directus POSTGRES_USER: directus POSTGRES_PASSWORD: strongpassword volumes: - ./data/postgres:/var/lib/postgresql/data directus: image: directus/directus:latest depends_on: - database ports: - "8055:8055" environment: KEY: replace-this-with-a-long-random-key SECRET: replace-this-with-a-long-random-secret DB_CLIENT: pg DB_HOST: database DB_PORT: 5432 DB_DATABASE: directus DB_USER: directus DB_PASSWORD: strongpassword ADMIN_EMAIL: admin@example.com ADMIN_PASSWORD: strongpassword volumes: - ./uploads:/directus/uploads - ./extensions:/directus/extensions astro: build: ./english-site depends_on: - directus ports: - "4321:4321" environment: DIRECTUS_URL: http://directus:8055

With this setup, the Astro container can fetch data from Directus using http://directus:8055 because directus is the Compose service name.

Step 17: Testing Checklist

Test the setup in this order:

  1. Open Directus and confirm categories and articles exist.

  2. Open the Astro homepage or article listing and confirm articles load.

  3. Open a category page such as /category/software and confirm the correct articles appear.

  4. Open an article page such as /articles/example-post and confirm the body renders.

  5. Check article text contrast and confirm it no longer blends into the background.

  6. Restart containers and confirm content still loads correctly after rebuilds.

Common Problems and Fixes

Problem Likely cause Fix
Category page shows “No articles found” Related category data not expanded correctly. Fetch category.* and filter in Astro by article.category?.slug.
Article page exists but body is blank Wrong article body field name or non-HTML rich text output. Confirm the Directus field name and output format.
Text blends into background Inherited colors are too low-contrast for the current theme. Add explicit high-contrast text colors to the article wrapper and rendered content.
Astro container cannot reach Directus Wrong DIRECTUS_URL inside Docker. Use http://directus:8055 in Compose, not browser-style localhost assumptions.
Astro Docker container starts but app is unreachable App not bound to all interfaces. Set HOST=0.0.0.0 and expose the correct port.

Key Lessons

  • Astro dynamic routes depend on getStaticPaths() when using static generation or prerendered dynamic routes.

  • Directus relation fields often need explicit expansion, such as category.*, before they are easy to use in page templates.

  • Filtering in Astro is often the clearest short-term fix when a nested Directus relation filter is uncertain.

  • Docker networking changes how environment URLs work, so service names matter when Astro and Directus run in the same stack.

  • Rich article content needs explicit styling and contrast tuning to remain readable across themes.

Once this setup is stable, the most useful next steps are adding better article typography, featured images, related articles, and a graceful fallback for missing content.

For deployment beyond local Docker, add a reverse proxy, HTTPS, stronger secrets, and environment-specific configuration for Directus and Astro.