Most Drupal installers stop at "it's running."
Mine doesn't.
The problem nobody talks about
You follow the tutorial. You provision a server. You install Drupal. The site loads. You think you're done.
You're not.
Your trusted_host_patterns might be unconfigured — meaning any Host header gets accepted, opening you to cache poisoning. Your private file path might be unset — meaning files uploaded to private:// are publicly accessible via direct URL. Your Redis might be running but not actually wired as Drupal's cache backend. Your TLS cert might have 12 days left and you have no alert for it.
The site looks fine. The vulnerabilities are invisible.
Every Drupal developer I know has shipped something that looked fine and wasn't. Including me.
What I built
Actools is a Drupal 11 installer for Hetzner VPS. One command, complete stack — Caddy 2, PHP 8.3-FPM, MariaDB 11.4, Redis 7, XeLaTeX PDF worker, automated backups.
That part exists. There are other installers.
What's different is this:
$ actools audit
25 checks. Four categories. A score out of 10. Fix commands attached to every finding.
=== ACTOOLS DRUPAL AUDIT ===
[DRUPAL]
PASS Security advisories: none found
PASS trusted_host_patterns: configured
PASS Error display: hidden
PASS Private file path: configured and writable
[INTEGRATION]
PASS Redis: write/read/TTL confirmed
PASS Queue worker: enqueue test passed
PASS HTTP: Cache-Control header present
[STACK]
PASS Containers: all 5/5 running
PASS Site response: HTTP 200
PASS TLS: valid, 90 days remaining
PASS MariaDB: reachable
PASS Worker container: healthy
[SECURITY]
PASS HTTPS: HTTP redirects to HTTPS
PASS HSTS header: present
PASS X-Frame-Options header: present
PASS Server header: hidden
─────────────────────────────────────────
PASS: 22 WARN: 5 FAIL: 1
Audit score: 6/10
Fix FAIL items before next deploy.
Not a dashboard. Not a Drupal module. A CLI tool that tells you the truth about your own server.
The line that started it
At the top of audit.sh there's a comment:
# The Drupal community has enough Report modules.
# What it lacks is a CLI tool that says:
# I found a problem. I won't let you deploy until you run this specific command to fix it.
That's the whole product philosophy in three lines. Written before a single check existed.
What it checks
Drupal layer
- Security advisories via
drush pm:security -
trusted_host_patterns— reads settings.php and verifies it's active - Config drift — but only if the sync directory has a baseline (fresh installs get INFO, not false WARNING)
- Error display mode
- Session cookie security flags
- Queue backlog
Integration layer
- Redis behavioral test — not just "is it running" but write/read/TTL cycle
- Redis as actual Drupal cache backend
- HTTP cache headers
- Queue worker — enqueues a test job and verifies processing
- Private file path — verifiable and writable
Stack layer
- All containers running
- HTTP 200 response
- TLS validity and days remaining
- Disk usage
- Memory available
- Backup existence and age
- MariaDB reachability
- Worker container health
Security layer
- HTTPS redirect
- HSTS header
- X-Frame-Options
- X-Content-Type-Options
- Server header hidden
- Referrer-Policy
- Docker image pinning
The honest score
Fresh install scores 6/10.
Not 10. Not "everything is perfect." Six. Because on a brand new server there's no backup yet, Redis isn't wired as the cache backend by default, and a few medium-priority items need operator attention.
That's honest. A tool that gives you 10/10 on a fresh install is lying to you.
The score goes up as you fix things. Run actools backup. Wire Redis. Pin your Docker images. Each fix moves the needle.
What I learned building it
Shell escaping across Docker layers is genuinely hard.
Injecting $settings['trusted_host_patterns'] into a PHP file through bash → docker exec → bash → heredoc — every layer eats escape characters differently. I went through printf, echo -e, inline quoting, and eventually landed on the only solution that actually works:
docker compose exec -T "$php_svc" bash -c "cat > /tmp/php_inject.php << 'EOF'
\$settings['trusted_host_patterns'] = array('^${domain_escaped}\$', '^.*\\.${domain_escaped}\$');
// trusted_host_patterns_active
EOF
cat /tmp/php_inject.php >> /path/to/settings.php
rm -f /tmp/php_inject.php"
Quoted heredoc inside the container. Write to temp file. Append. Delete. No escaping war.
Three AI systems independently converged on this exact pattern when I described the problem. That's usually a sign it's right.
Idempotency checks need to be precise.
My first idempotency check used grep -q file_private_path settings.php. It matched the commented-out default line # $settings['file_private_path'] = ''; and skipped the injection every time. The installer said "set" — nothing was actually written.
Fix: grep -q "^$settings\['file_private_path'\]" — anchor to the start of line, require the actual PHP assignment.
Cache matters more than you think.
Settings written to settings.php aren't visible to Drupal until the cache is rebuilt. The installer now runs drush cr immediately after both injections. Obvious in hindsight. Invisible until you're staring at an audit that says CRITICAL on something you just fixed.
The stack
- Drupal 11 + PHP 8.3-FPM
- Caddy 2 (automatic HTTPS, security headers, rate limiting)
- MariaDB 11.4
- Redis 7
- XeLaTeX worker (PDF generation, self-contained)
- GitHub Actions CI (bats + shellcheck + Trivy + CodeQL)
- Hetzner CX22 — €10/month
MIT license. No lock-in. The installer is free. What you install today stays yours. Future modules are optional.
Try it
git clone https://github.com/actools-pl/actoolsDrupal.git
cd actoolsDrupal
cp actools.env.example actools.env && nano actools.env
sudo ./actools.sh
actools audit
You need a Hetzner VPS running Ubuntu 24.04 and a domain pointed at it. That's it.
First tester feedback welcome at GitHub Issues.
Built with Claude. For Claude.
Top comments (0)