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.
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:
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/extensionsStep 3: Start Directus
From the folder containing docker-compose.yml, start Directus with Docker Compose:
docker compose up -dOnce 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:
DIRECTUS_URL=http://localhost:8055If 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:
---
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:
---
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:
<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:
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:
.DS_Store
node_modules
distStep 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:
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:
docker build -t english-site .Run it locally and map a local port to the container port:
docker run -p 4321:4321 --env DIRECTUS_URL=http://host.docker.internal:8055 english-siteIf 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:
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:8055With 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:
-
Open Directus and confirm categories and articles exist.
-
Open the Astro homepage or article listing and confirm articles load.
-
Open a category page such as
/category/softwareand confirm the correct articles appear. -
Open an article page such as
/articles/example-postand confirm the body renders. -
Check article text contrast and confirm it no longer blends into the background.
-
Restart containers and confirm content still loads correctly after rebuilds.
Common Problems and Fixes
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.
Recommended Next Improvements
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.