Drupal 11 in Docker loads without CSS: check for a missing .htaccess

    A fresh Drupal 11 site running in the official drupal:11-apache container comes up with the page structure intact but no styling. Olivero renders as plain HTML. A few small core stylesheets load, but the main ones return 404.

    The cause is usually one missing file: the .htaccess in the web root.

    Symptoms

    View the page source and request the stylesheets directly:

    curl -s https://example.com/ | grep -o 'href="[^"]*\.css[^"]*"'
    curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
      "https://example.com/sites/default/files/css/css_0eym....css?delta=0&language=en&theme=olivero&include=..."
    

    The pattern to look for:

    Request Result
    /core/themes/olivero/css/components/...css 200 text/css
    /sites/default/files/css/css_*.css 404 text/html; charset=iso-8859-1

    The iso-8859-1 charset matters. That 404 page comes from Apache, not from Drupal. Drupal never saw the request.

    Why aggregated CSS depends on .htaccess

    Since Drupal 10.1, CSS and JS aggregates are built on demand. The page references a file under sites/default/files/css/ that does not exist yet. When the browser asks for it, Apache finds no file and rewrites the request to index.php. Drupal then builds the aggregate, writes it to disk and serves it. Later requests hit the file directly.

    That rewrite lives in the web root's .htaccess:

    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !=/favicon.ico
    RewriteRule ^ index.php [L]
    

    Without it, Apache answers the missing file with its own 404 and the aggregate is never generated. Individual core files still load because they exist on disk, which is why the site looks half-styled rather than completely broken.

    How the file goes missing

    The usual culprit is copying the web root with a glob:

    cp -r /source/web/* /target/web/
    

    The shell glob * skips dotfiles, so index.php, robots.txt and update.php arrive and .htaccess does not. Check with ls -la, not ls:

    ls -la /opt/drupal/web | grep htaccess
    

    Fix

    Drupal core ships the canonical file in its scaffold assets. Restore it from there:

    docker exec drupal_app cp /opt/drupal/web/core/assets/scaffold/files/htaccess /opt/drupal/web/.htaccess
    docker exec drupal_app chmod 644 /opt/drupal/web/.htaccess
    

    If you manage the project with Composer, composer drupal:scaffold puts back every scaffold file, including .htaccess and robots.txt.

    No cache rebuild is needed. Reload the page and the aggregates are generated on the first request.

    Check that it also protects you

    The same .htaccess blocks direct access to files that should never be served. With it missing, those protections are gone too. After restoring it:

    curl -s -o /dev/null -w "%{http_code}\n" https://example.com/core/core.services.yml
    curl -s -o /dev/null -w "%{http_code}\n" https://example.com/sites/default/settings.php
    

    Both should return 403. If you get 200 for the YAML file, the rewrite and access rules are still not being applied: check that mod_rewrite is enabled and that the virtual host allows AllowOverride All for the web root.

    When copying a web root in the future, copy the directory itself (cp -a web /target/) or use rsync -a so dotfiles come along.