Configuration

Users and permissions

Non-root by default, what changes in root mode, file ownership for bind mounts, and a read-only root filesystem recipe.

Non-root by default

Every image runs as appuser, UID 1000 and GID 1000, unless you pass --user. In this mode:

  • Ports below 1024 cannot be bound. Use NGINX_PORT=8080 and NGINX_SSL_PORT=8443.
  • Cron is not available. The entrypoint starts Nginx and PHP-FPM directly and supervises them itself.
  • PHP-FPM runs its workers as appuser too; the user and group pool directives are stripped automatically.
  • Nothing in /etc is writable. Configuration is rendered into /run/nginx and /run/php.

Root mode

docker run --rm -d -p 80:80 --user root adamrollogi/php-nginx:8.4

Supervisor becomes PID 1's child and manages cron, Nginx and PHP-FPM. Nginx workers run as nginx, FPM workers as nobody. Use this when you need privileged ports or cron jobs; drop files into /etc/cron.d/ or mount a crontab.

File ownership on bind mounts

PHP writes as UID 1000. If your host files are owned by another user, either run with a matching UID or give the mounted directories the right ownership:

docker run --rm -d -p 8080:8080 -e NGINX_PORT=8080 \
  --user "$(id -u):$(id -g)" \
  -v $(pwd)/public:/var/www/html \
  adamrollogi/php-nginx:8.4

Running as an arbitrary UID works because the runtime directories (/run/nginx, /run/php, /var/log, /var/lib/nginx) are group-writable by GID 1000 and the entrypoint creates what it needs with best-effort mkdir. If you pick a UID outside that group, mount those paths as tmpfs with matching ownership, as in the read-only recipe below.

Read-only root filesystem

Because configuration never lands in /etc, the web images run cleanly with --read-only. Any tampering with rendered config or logs is discarded on restart, since tmpfs mounts are recreated.

docker run --rm -d -p 8080:8080 \
  -e NGINX_PORT=8080 \
  --read-only \
  --tmpfs /run:uid=1000,gid=1000 \
  --tmpfs /var/log:uid=1000,gid=1000 \
  --tmpfs /var/lib/nginx:uid=1000,gid=1000 \
  --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges \
  -v $(pwd)/public:/var/www/html:ro \
  adamrollogi/php-nginx:8.4

The uid/gid options matter: Docker mounts tmpfs as root-owned by default. Add --tmpfs /etc/nginx/ssl:uid=1000,gid=1000 if you use NGINX_SSL_SELF_SIGNED=true.

In Compose:

compose.yaml
services:
  app:
    image: adamrollogi/php-nginx:8.4
    read_only: true
    tmpfs:
      - /run:uid=1000,gid=1000
      - /var/log:uid=1000,gid=1000
      - /var/lib/nginx:uid=1000,gid=1000
      - /tmp
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]

Why config lives in /run

In non-root mode the user that renders configuration is the same user that executes PHP, so a file-write vulnerability in an application could in principle rewrite php.ini and gain persistence. Keeping /etc root-owned and rendering into /run means the writable surface is a tmpfs-able directory that resets on restart, and the read-only recipe above removes persistence entirely. It is a mitigation, not a sandbox; treat application-level write bugs as the real problem.

No setuid binaries

find / -perm /6000 returns nothing in any image. busybox-suid is not installed and the setuid bits from the shadow package are stripped at build time, so there is no su, passwd or crontab privilege escalation path for a compromised process.