DEV Community

Cover image for Serving llms.txt from a Dockerized WordPress + Nginx Setup
Serhii Pylypenko
Serhii Pylypenko

Posted on

Serving llms.txt from a Dockerized WordPress + Nginx Setup

If you're running WordPress in Docker with Nginx and PHP-FPM in separate containers, you've probably hit this kind of problem before: a plugin generates a file inside the WordPress container, but Nginx can't see it because the filesystems are isolated.

That's exactly what happened when I tried to enable llms.txt support via AIOSEO on a typical Dockerized WordPress setup.


The Problem

llms.txt is a new standard (similar to robots.txt) that helps LLMs understand and index your site's content. AIOSEO generates it automatically — and so does Yoast SEO. Both plugins follow the same pattern: they write a physical file to the WordPress root directory. In a standard single-server setup, this works fine. In Docker, it doesn't.

Here's the catch:

A typical Dockerized WordPress setup looks like this:

  • Nginx container — handles all incoming HTTP requests
  • WordPress (PHP-FPM) container — runs the application

The /var/www/html directory is not a shared volume between them. This is intentional — keeping code immutable and containers isolated is good practice. But it means when AIOSEO writes llms.txt into the WordPress container's filesystem, Nginx simply can't find it and returns a 404.


The Solution: PHP Bridge Pattern

Instead of adding a shared volume (which would compromise the immutability design), I used a PHP bridge: Nginx passes llms.txt requests to WordPress, which intercepts and serves the file directly.

Two pieces needed:

1. MU-Plugin (WordPress side)

Create wp-content/mu-plugins/llms-txt-bridge.php:

<?php
/**
 * Plugin Name: LLMs.txt Bridge
 * Description: Serves llms.txt from WP container for Nginx
 */
add_action('init', function() {
    $uri = strtok($_SERVER['REQUEST_URI'], '?');

    if ($uri !== '/llms.txt' && $uri !== '/llms-full.txt') {
        return;
    }

    $filename     = ltrim($uri, '/');
    $uploads_dir  = wp_upload_dir()['basedir'];
    $uploads_file = $uploads_dir . '/' . $filename;
    $abspath_file = ABSPATH . $filename;

    // Sync from ABSPATH to uploads dir if ABSPATH version is newer
    if (file_exists($abspath_file)) {
        $abspath_time = filemtime($abspath_file);
        $uploads_time = file_exists($uploads_file) ? filemtime($uploads_file) : 0;

        if ($abspath_time > $uploads_time) {
            copy($abspath_file, $uploads_file);
        }
    }

    // Serve from uploads if available
    if (file_exists($uploads_file)) {
        header('Content-Type: text/plain; charset=utf-8');
        readfile($uploads_file);
        exit;
    }
}, 1);
Enter fullscreen mode Exit fullscreen mode

Why sync to uploads? The uploads directory is typically a mounted volume, meaning the file survives container restarts. ABSPATH files don't.

2. Nginx Config

Add this location block to your nginx.conf:

location ~* ^/llms(-full)?\.txt$ {
    try_files $uri /index.php?$args;
    expires 30d;
    access_log off;
    log_not_found off;
    add_header Cache-Control "public, no-transform";
}
Enter fullscreen mode Exit fullscreen mode

try_files $uri /index.php means: try to serve the file statically first; if not found, pass to PHP. Since Nginx can't see the file in the WP container, it always falls through to PHP — which is exactly what we want.


How It Works

GET /llms.txt
      │
      ▼
┌─────────────────────────────┐
│  Nginx Container            │
│  try_files → file not found │
│  → passes to PHP            │
└─────────────────────────────┘
      │
      ▼
┌─────────────────────────────┐
│  WordPress Container        │
│  MU-plugin intercepts init  │
│  Syncs file if needed       │
│  Serves with correct headers│
└─────────────────────────────┘
      │
      ▼
  200 OK (text/plain)
Enter fullscreen mode Exit fullscreen mode

Don't Commit the Generated Files

Add these to .gitignore:

wp/llms.txt
wp/llms-full.txt
Enter fullscreen mode Exit fullscreen mode

These files are generated per environment and contain domain-specific URLs. If you accidentally committed them, clean up with:

git rm --cached wp/llms.txt
git rm --cached wp/llms-full.txt
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

404 on /llms.txt

Check that the MU-plugin is in the container:

docker exec your_wp_container ls -la /var/www/html/wp-content/mu-plugins/
Enter fullscreen mode Exit fullscreen mode

Check that AIOSEO generated the file:

docker exec your_wp_container ls -la /var/www/html/llms.txt
Enter fullscreen mode Exit fullscreen mode

If the file doesn't exist, go to AIOSEO settings and toggle LLMs.txt off and on to regenerate.

File returns wrong domain (e.g., mysite.test instead of mysite.com)

The file was generated on a local environment. Fix:

# Delete the stale file
docker exec your_wp_container rm /var/www/html/llms.txt

# Then regenerate via AIOSEO in WP Admin
Enter fullscreen mode Exit fullscreen mode

Nginx not passing requests to PHP

Verify the location block is active:

docker exec your_nginx_container nginx -T | grep llms
Enter fullscreen mode Exit fullscreen mode

Why Not Just Use a Shared Volume?

A shared volume would work technically, but it breaks the immutability model — if both containers write to the same filesystem, you lose the clean separation between "application code" and "runtime state." The PHP bridge keeps infrastructure clean while solving the practical problem.


The Real Fix: Dynamic Generation

The PHP bridge works — but it's a deliberate trade-off, not a clean solution. You're syncing files between paths, intercepting requests at init, and hoping nothing breaks when AIOSEO updates.

There's a cleaner approach that would eliminate the problem entirely: serve llms.txt dynamically via WordPress rewrite rules, with no physical file involved.

WordPress already does exactly this for robots.txt. Before WordPress 5.3, you needed a physical robots.txt file in the document root. Since 5.3, if no physical file exists, WordPress generates the response dynamically through a rewrite rule — no file on disk, no filesystem dependency, works everywhere including read-only environments and isolated containers.

The same pattern applied to llms.txt would look like this:

GET /llms.txt
      │
      ▼
  WordPress rewrite rule intercepts
      │
      ▼
  Check transient cache
      │
      ├── Cache hit → serve immediately
      │
      └── Cache miss → generate content → store in transient → serve
Enter fullscreen mode Exit fullscreen mode

Benefits over the file-based approach:

  • No Docker filesystem isolation problem (nothing writes to disk)
  • No permission issues on managed hosting
  • Works on read-only filesystems
  • Cache invalidation is explicit and controllable
  • Generation happens on first request, not on plugin save

For SEO plugin authors, this means replacing file_put_contents(ABSPATH . 'llms.txt', ...) with a rewrite endpoint and transient cache. The content generation logic stays identical — only the delivery mechanism changes.

For WordPress core, there's a reasonable case for adding llms.txt infrastructure the same way robots.txt was added in 5.3: a default dynamic endpoint with filters that plugins can hook into. Given how quickly LLM indexing is becoming relevant for content sites, this seems like a natural next step.

Until either happens — the PHP bridge above does the job.


Key Takeaways

  • Isolated Docker containers create real filesystem problems for file-based WordPress features
  • The PHP bridge pattern (Nginx → PHP fallback) is a clean solution that doesn't require infrastructure changes
  • MU-plugins are ideal for this: no activation needed, always loaded
  • Nginx caches the response for 30 days after first request, so performance is fine
  • Generated files with domain URLs should never be in git

If you're running a similar multi-container WordPress setup, this pattern applies to any file that WordPress generates dynamically — not just llms.txt.

Running a similar setup? Let me know how you handled it — or if you've seen SEO plugins already moving toward dynamic generation.

Top comments (0)