Here is the short version: these are Docker images for the web and mail stack that runs deb.myguard.nl itself — the same hardened NGINX, Angie, ModSecurity, PHP, Postfix, Dovecot and rspamd builds, just wrapped in containers and rebuilt every single day. You docker pull, you docker compose up -d, and you are running the exact binaries we trust on our own production box.

If that sentence had three words you did not recognise, do not worry — that is the whole point of this post. By the end you will know which image to pull, how to start it, why it is split into three separate Linux users for security, and how to turn HTTP/3 on with a single line. No prior container experience assumed. Let us go.

What these images actually are

Most public Docker images for NGINX or Angie ship the plain upstream binary with the plain upstream defaults. Fine, but boring — and not very hardened. These images are different: they are built on top of the same Debian/Ubuntu packages we publish at deb.myguard.nl, with the same patches, the same dynamic modules, and the same OpenSSL fork. So what you run in a container matches what you would get from apt install on a Debian box pointed at our repo. HTTP/3, QUIC, kTLS, ModSecurity3 with the OWASP Core Rule Set, Brotli and Zstd compression — all already wired in. And because they rebuild daily, security fixes reach you without you lifting a finger.

The image lineup (or: which one do I actually pull?)

Pick the one that matches your stack. Every tag also exists as :ubu-* for Ubuntu builds — these examples use Debian (:deb-*) because most production folks run Debian, and we do not judge.

eilandert/nginx:deb-latest    # NGINX, no PHP — for static / reverse-proxy
eilandert/nginx:deb-php8.5    # NGINX + PHP-FPM 8.5
eilandert/nginx:deb-php8.4
eilandert/nginx:deb-php8.2
eilandert/nginx:deb-php8.0
eilandert/nginx:deb-php7.4
eilandert/nginx:deb-php5.6    # yes, still here for the truly stuck
eilandert/nginx:deb-multi     # 5.6 / 7.4 / 8.0 / 8.2 / 8.4 / 8.5 in one image

eilandert/angie:deb-latest    # Angie — drop-in replacement for NGINX
eilandert/angie:deb-php8.5
eilandert/angie:deb-multi

eilandert/php-fpm:deb-8.5     # PHP-FPM only (use behind your own proxy)
eilandert/php-fpm:deb-multi
eilandert/apache-phpfpm:deb-8.5   # Apache + PHP-FPM if you must
eilandert/angie-cms:latest        # Angie + PHP 8.5 + the entire CMS toolbox

All PHP-bearing images include Composer, WP-CLI, nullmailer, jemalloc and mimalloc out of the box. The PHP packages come from the excellent Ondřej Surý PPA (mirrored locally on deb.myguard.nl, so builds do not break when Launchpad has a bad day).

A five-minute docker-compose example

Mount the config directories to empty local paths and they get populated with working defaults on the first start. No “where do I copy the conf files from” dance. If you have ever spent an hour figuring out which sample.conf goes where, you will appreciate this.

services:
  angie:
    container_name: angie
    image: eilandert/angie:deb-php8.5
    stop_grace_period: 3s
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"     # UDP for HTTP/3 QUIC
    restart: always
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt:rw
      - ./angie/config:/etc/angie:rw
      - ./angie/php:/etc/php:rw
      - ./angie/log:/var/log/nginx:rw
      - ./angie/modsecurity:/etc/modsecurity:rw
      - ./angie/nullmailer:/etc/nullmailer:rw
      - ./cache/angie:/var/cache/nginx:rw
      - ./sites:/var/www:rw
    environment:
      - MALLOC=jemalloc
      - TZ=Europe/Amsterdam
      - PHP85=YES          # for :deb-multi only
      - NGX_MODULES=mod-http-modsecurity,mod-http-brotli,mod-http-headers-more-filter,mod-http-zstd

That is it. docker compose up -d and you have a hardened Angie with HTTP/3, ModSecurity, Brotli and Zstd compression. The container ships a healthcheck (/healthz on port 8181), so docker ps tells you when it is actually ready — not just when the container started.

Meet the users: phpfpm, www-data, and agent

This is the part worth slowing down for. If you have ever read a security writeup that said “the WordPress plugin escalated privileges because everything ran as www-data“, you will like this section. We split the container into three Linux accounts, each with one tiny, specific job — like a small, sensible team rather than one overworked intern doing everything.

  • www-data — the web server. Angie / NGINX / Apache workers run as this user. It reads your site’s files and talks to the PHP-FPM socket. That is its whole job.
  • phpfpm (uid 1500) — the PHP workers. This is where your code actually runs. Crucially, phpfpm is not a member of the www-data group. If a plugin gets popped, the attacker is stuck as phpfpm with no group membership that reaches anything dangerous.
  • agent (uid 1501) — the operator account. Real bash shell, home dir, supplementary www-data and sudo groups, and a passwordless entry in /etc/sudoers.d/10-agent. This is the user a human operator (or a Claude / Copilot agent doing maintenance) logs in as over SSH or docker exec.

The sudo binary itself is locked down: chmod 4750, owned root:sudo. Translation: only root and members of the sudo group can even execute the binary. The web server (www-data) and the PHP workers (phpfpm) are in neither the sudo group, so for them sudo does not exist — they cannot run it at all. Only agent is in the sudo group, and only agent has a sudoers entry. That is defense in depth: the binary permission and the sudoers rule both have to agree, and they only agree for agent.

To inject your SSH key, mount /home/agent/.ssh/authorized_keys at runtime or drop it in at build time. The .ssh directory is pre-created 0700 agent:agent, so you cannot accidentally make it world-readable.

The CMS image: angie-cms

The eilandert/angie-cms:latest image is Angie + PHP-FPM 8.5 + a complete WordPress-and-friends toolbox in a single container. Pull it, mount your site, and you are hosting. Nothing else to install. Picture the difference between “I just bought a cake kit” and “I have to drive to the supermarket for eggs at 11pm”. We are the cake kit.

What is inside, on top of the angie+php-fpm-8.5 base:

  • WP-CLI with doctor-command and profile-command pre-installed (the two best WordPress diagnostic tools nobody knows about).
  • Drush for Drupal sites: installed via Composer to /opt/drush and added to $PATH.
  • Composer with a sensible COMPOSER_HOME pointing at /var/www/.composer.
  • Image-optim toolchain: jpegoptim, optipng, pngquant, gifsicle, webp, plus libvips42 + php-vips for the speed fanatics.
  • ImageMagick with a hardened policy that disables the historically-exploitable coders (PS, EPS, PDF, MSL, URL — the whole ImageTragick family). Resource caps prevent upload-bomb OOMs.
  • Backup tooling: a built-in cms-backup script that snapshots DB + wp-content + a sanitised wp-config.php (secrets stripped) into /var/www/_backups/. Plus restic and rclone for off-site copies.
  • wp-cron via a real cron daemon: every minute, every /var/www/*/ doc-root with a wp-config.php gets its overdue events dispatched as phpfpm.
  • Admin shell tools: git, patch, mariadb-client, rsync, openssh-client, goaccess for log reports, jq, mc, nano, less.
  • PHP tuned for CMS workloads: memory_limit=512M, upload_max_filesize=64M, opcache.jit=tracing, a large realpath cache. Defaults that will not make a CMS cry.
  • Angie tuned for CMS workloads: client_max_body_size 64m, fastcgi_read_timeout 300s, gzip on, server tokens off. Drop-in WordPress and Drupal site snippets ship at /etc/angie/snippets/wordpress.conf and drupal.conf.

Bonus: the bootstrap auto-generates /root/.my.cnf from your $DB_HOST / $DB_USER / $DB_PASS environment variables, so when you docker exec -it cms bash and type mysql, it Just Works. Tiny touch, huge dignity boost.

Selecting modules at runtime

Set the NGX_MODULES environment variable to a comma-separated list. No custom Dockerfile, no rebuild for “oops, I forgot Brotli”. All 50+ modules from the Angie / NGINX modules page are available:

NGX_MODULES=mod-http-modsecurity,mod-http-geoip2,mod-http-brotli,mod-http-zstd,mod-http-lua,mod-http-headers-more-filter

Leave NGX_MODULES unset and every module loads at boot. That is fine for testing, less fine for a hot production box where each module costs a little startup time. Pick what you need.

HTTP/3, QUIC, and the small luxuries

Expose UDP 443 in your compose file and HTTP/3 just works. The OpenSSL fork shipped with the image speaks QUIC, the server is compiled with HTTP/3 support, and you only need a one-line vhost snippet to advertise it via Alt-Svc:

server {
    listen 443 ssl;
    listen 443 quic reuseport;
    http3 on;
    add_header Alt-Svc 'h3=":443"; ma=86400';
}

Build speed: BuildKit cache mounts everywhere

Every Dockerfile starts with # syntax=docker/dockerfile:1.7 and uses --mount=type=cache for apt’s archive and list caches. Translation: rebuilds skip the package-download phase entirely once the cache is warm. The first build pulls everything once; every rebuild after that is 5–30× faster on the apt step. If you are tweaking a Dockerfile in a loop, you will feel this.

FAQ

Why a separate phpfpm user instead of just using www-data?

Because the web server and the PHP code do not share a threat model. The web server processes HTTP — pretty constrained. PHP runs arbitrary user code, including whatever plugin a tired admin clicked “install” on at 2am. Giving them different Linux accounts means a plugin RCE does not automatically inherit the web server’s reach. Same reason you do not run your database as root.

Can I still bind-mount my site files and have permissions work?

Yes. /var/www is pre-created as phpfpm:www-data mode 2750. The setgid bit means any file created in there inherits the www-data group, so static assets stay readable by Angie. If you mount from the host, chown -R 1500:33 ./sites on the host before mounting (uid 1500 = phpfpm, gid 33 = www-data on Debian/Ubuntu).

What happens if I don’t set NGX_MODULES?

Every module loads. Two of them, mod-http-lua and mod-stream-lua, get auto-disabled because they need extra configuration to start cleanly and we would rather not greet you with a crash log. The container prints a warning telling you to set NGX_MODULES. To silence it, touch /etc/angie/modules-enabled/.quiet and we will leave you alone.

How do I update? Do I have to rebuild?

Just docker pull eilandert/angie:deb-php8.5 and docker compose up -d. The images rebuild daily from upstream — security fixes, package updates, the works. No rebuild on your side. If you run Watchtower or Diun, point it at the registry and forget about it.

Where do logs go?

Access and error logs go to stdout / stderr, so docker logs shows everything. PHP-FPM workers’ slowlog is also piped to stderr (via catch_workers_output=yes + slowlog=/proc/self/fd/2), so you can watch slow CMS pages in real time without exec-ing into the container.

Is it safe to give the agent user passwordless sudo?

Inside the container, yes — by design. The container’s threat surface is the network and your code, not “what if I cannot trust the local operator”. The point is keeping that sudo path unreachable from PHP and the web server, which we do by owning the binary root:sudo at mode 4750 (so neither www-data nor phpfpm can execute it) and granting sudoers to agent only. Do not want the agent user at all? Set --user phpfpm on docker run and you are back to a no-shell container.

Further reading