Phase 3: site templates & base images

Add the building blocks the provisioning CLI renders per site.

Base images (site-templates/images):
- php-fpm: non-root (www-data) php:<ver>-fpm-alpine with pdo_mysql, mysqli,
  gd, intl, zip, opcache, exif; tuned php.ini + pool; built per PHP version.
- nginx: hardened nginx:1.27-alpine with shared security + fastcgi snippets;
  per-site server block mounted at runtime.
- build.sh: build + Trivy-scan (+ optional push) for both images.

Profile templates (site-templates/profiles), Jinja2 rendered:
- static, redirect (tiny nginx 301/302), custom-php (nginx + our php-fpm,
  optional DB), wordpress (nginx + official wordpress-fpm, DB required,
  upload-exec denied). Only nginx carries Traefik labels and joins proxy;
  php-fpm uses the private <slug>_net and joins platform only when a DB is
  needed. Secrets stay in a git-ignored .env, not the compose.

CI: .forgejo/workflows/images.yml builds/scans images (gitleaks + Trivy).
README documents the render context and the Phase 4 web-root ownership item.

Templates validated: all profiles render to valid compose YAML across the
database on/off branches and single/multi-domain host rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bart Van Geyt 2026-07-07 17:06:25 +02:00
parent 41a01c60ef
commit edfb4c44b8
20 changed files with 511 additions and 6 deletions

View file

@ -0,0 +1,29 @@
# Build + scan the base images on push. Runs on a Forgejo Actions runner (set up
# in a later phase). Uses the built-in registry; TOKEN/REGISTRY come from repo
# secrets/vars.
name: images
on:
push:
branches: [main]
paths:
- "site-templates/images/**"
- ".forgejo/workflows/images.yml"
jobs:
build-scan:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Secret scan (gitleaks)
uses: gitleaks/gitleaks-action@v2
- name: Log in to the registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
- name: Build + Trivy-scan + push images
env:
REGISTRY: ${{ vars.REGISTRY_HOST }}/heleos
PUSH: "1"
SCAN: "1"
run: ./site-templates/images/build.sh

View file

@ -1,10 +1,70 @@
# site-templates
Building blocks the provisioning CLI renders from (Phase 3). Empty until then.
Building blocks the provisioning CLI (Phase 4) renders into
`deployments/<customer>/<site>/`. Two parts: **base images** and **profile
templates**.
**Planned contents:**
- Dockerfiles for standard images: php-fpm (non-root), nginx, static base.
- One compose template per profile: `static`, `redirect`, `custom-php`,
`wordpress` — placeholders filled from a site's `site.yaml`.
## Images (`images/`)
Profiles are specified in [../docs/04-site-profiles.md](../docs/04-site-profiles.md).
Hardened base images, built and pushed to the Forgejo registry.
| Image | Base | Notes |
|-------|------|-------|
| `nginx` | `nginx:1.27-alpine` | Hardened `nginx.conf` + reusable snippets (`security.conf`, `fastcgi-php.conf`). Per-site server block mounted at runtime. |
| `php-fpm` | `php:<ver>-fpm-alpine` | **Runs as non-root** (`www-data`). Extensions: pdo_mysql, mysqli, gd, intl, zip, opcache, exif. OPcache tuned. Built per PHP version. |
Build (and Trivy-scan) locally:
```bash
REGISTRY=git.example.com/heleos ./images/build.sh # build + scan
REGISTRY=git.example.com/heleos PUSH=1 ./images/build.sh # also push
```
CI does the same on push — see [`.forgejo/workflows/images.yml`](../.forgejo/workflows/images.yml).
WordPress uses the official `wordpress:<ver>-fpm-alpine` image directly (it
bundles WordPress + php-fpm); only `custom-php` uses our `php-fpm` image.
## Profiles (`profiles/`)
One directory per profile (see
[../docs/04-site-profiles.md](../docs/04-site-profiles.md)). Each has a
`docker-compose.yml.j2` and its nginx config template.
| Profile | Containers | DB | Networks |
|---------|-----------|----|---------|
| `static` | nginx (web root ro) | — | proxy |
| `redirect` | nginx (301/302) | — | proxy |
| `custom-php` | nginx + php-fpm | optional | proxy, site, [platform] |
| `wordpress` | nginx + wordpress-fpm | required | proxy, site, platform |
Routing pattern (all profiles): only **nginx** carries Traefik labels and joins
`proxy`; php-fpm joins the private `site` network (`<slug>_net`) and, when a DB
is used, the shared internal `platform` network to reach MariaDB. nginx reaches
php-fpm at `fpm:9000`.
## Render context
The CLI renders the `.j2` files with these variables (source: the site's
`site.yaml` + platform config):
| Variable | Example | Meaning |
|----------|---------|---------|
| `customer` / `site` | `acme` / `shop` | Ids. |
| `slug` | `acme-shop` | `<customer>-<site>`; Docker/router key. |
| `slug_underscored` | `acme_shop` | For DB names (`db_`, `u_`). |
| `domains` | `[shop.acme.com]` | Host rule is `Host(\`d1\`) || Host(\`d2\`)…` |
| `webroot` | `/tank/customers/acme/shop/web` | Bind-mounted web root. |
| `database` | `true`/`false` | custom-php only; wordpress is always true. |
| `redirect_to` / `redirect_code` | `https://acme.com` / `301` | redirect profile. |
| `nginx_image` / `php_image` / `wordpress_image` | `git.example.com/heleos/nginx:latest` | Resolved image refs. |
| `resources.cpu` / `resources.memory` | `1.0` / `512m` | Per-project limits. |
Secrets (DB password) are **not** rendered into the compose file — they go into a
git-ignored `.env` (from `secrets.enc.yaml`) that the compose reads via
`env_file`.
## Provisioning note (for Phase 4)
The web root dataset must be writable by the php-fpm user (`www-data`, uid 82 in
these alpine images) **and** by the customer's SFTP user. Provisioning will set
web-root ownership/permissions accordingly (e.g. shared group + setgid) — this is
an open item to finalize in the Phase 4 CLI, not baked into these templates.

38
site-templates/images/build.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Build (and optionally scan/push) the heleos base images.
#
# REGISTRY=git.example.com/heleos ./build.sh # build + Trivy scan
# REGISTRY=git.example.com/heleos PUSH=1 ./build.sh # also push
#
# PHP_VERSIONS controls which php-fpm tags are built.
set -euo pipefail
REGISTRY="${REGISTRY:-heleos}" # e.g. git.example.com/heleos
PUSH="${PUSH:-0}"
SCAN="${SCAN:-1}" # run Trivy if available
PHP_VERSIONS="${PHP_VERSIONS:-8.3 8.2}"
HERE="$(cd "$(dirname "$0")" && pwd)"
scan() {
if [ "$SCAN" = "1" ] && command -v trivy >/dev/null 2>&1; then
trivy image --severity HIGH,CRITICAL --exit-code 1 --no-progress "$1"
else
echo " (skipping Trivy scan for $1)"
fi
}
maybe_push() { [ "$PUSH" = "1" ] && docker push "$1" || true; }
echo "==> nginx"
docker build -t "${REGISTRY}/nginx:latest" "${HERE}/nginx"
scan "${REGISTRY}/nginx:latest"
maybe_push "${REGISTRY}/nginx:latest"
for v in $PHP_VERSIONS; do
echo "==> php-fpm ${v}"
docker build --build-arg "PHP_VERSION=${v}" -t "${REGISTRY}/php-fpm:${v}" "${HERE}/php-fpm"
scan "${REGISTRY}/php-fpm:${v}"
maybe_push "${REGISTRY}/php-fpm:${v}"
done
echo "Done."

View file

@ -0,0 +1,10 @@
# heleos nginx base image. Ships a hardened main config + reusable snippets.
# The per-site server block is provided at runtime as a mounted
# /etc/nginx/conf.d/default.conf (rendered from a profile template).
FROM nginx:1.27-alpine
COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY conf/snippets/ /etc/nginx/snippets/
# Access/error logs already go to stdout/stderr in the base image.
EXPOSE 80

View file

@ -0,0 +1,32 @@
user nginx;
worker_processes auto;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off;
client_max_body_size 64m;
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
# Per-site server block is mounted here at runtime.
include /etc/nginx/conf.d/*.conf;
}

View file

@ -0,0 +1,7 @@
# Standard FastCGI params for passing .php requests to php-fpm.
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
include fastcgi_params;
fastcgi_index index.php;
fastcgi_read_timeout 60s;

View file

@ -0,0 +1,13 @@
# Shared hardening rules, included by every site server block.
# Deny dotfiles (e.g. .git, .env, .htaccess) except ACME well-known.
location ~ /\.(?!well-known).* {
deny all;
access_log off;
log_not_found off;
}
# Block common sensitive files.
location ~* \.(engine|inc|ini|log|sh|sql|conf|bak)$ {
deny all;
}

View file

@ -0,0 +1,27 @@
# heleos php-fpm base image — runs as non-root (www-data, uid 82 on alpine).
# Build arg PHP_VERSION selects the runtime; tag the image to match.
ARG PHP_VERSION=8.3
FROM php:${PHP_VERSION}-fpm-alpine
# Build extensions with dev headers, then keep only the runtime shared libs.
RUN set -eux; \
apk add --no-cache --virtual .build-deps \
icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
pdo_mysql mysqli gd intl zip opcache exif; \
runDeps="$( \
scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \
| tr ',' '\n' | sort -u | awk 'system("[ -e /usr/local/lib/"$1" ]") == 0 { next } { print "so:" $1 }' \
)"; \
apk add --no-cache $runDeps icu-libs libzip libpng libjpeg-turbo freetype oniguruma; \
apk del .build-deps
# Platform php + pool config.
COPY php.ini /usr/local/etc/php/conf.d/zz-heleos.ini
COPY www.conf /usr/local/etc/php-fpm.d/zz-heleos.conf
# Drop privileges: the container runs entirely as www-data. php-fpm listens on
# TCP 9000 (unprivileged), so no root is needed.
USER www-data
EXPOSE 9000

View file

@ -0,0 +1,22 @@
; heleos php defaults. Sites may override via a mounted conf.d snippet.
expose_php = Off
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 66M
max_execution_time = 60
max_input_vars = 3000
date.timezone = UTC
; OPcache — tuned for typical PHP/WordPress workloads.
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
opcache.validate_timestamps = 1
; Do not leak errors to visitors (log to stderr → container logs → Loki).
display_errors = Off
log_errors = On
error_log = /dev/stderr

View file

@ -0,0 +1,21 @@
; heleos php-fpm pool. The container already runs as www-data (see Dockerfile
; USER), so the pool does not set user/group (it can't setuid as non-root).
[www]
listen = 9000
; Process manager — dynamic, conservative defaults for a shared host.
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
; Send worker stdout/stderr to the container logs.
catch_workers_output = yes
decorate_workers_output = no
clear_env = no
; Health/status endpoints (used by monitoring in Phase 6).
pm.status_path = /status
ping.path = /ping

View file

@ -0,0 +1,4 @@
# Generated per-site by the CLI (Phase 4) into deployments/<customer>/<site>/.env
# (git-ignored; real value comes from secrets.enc.yaml). Present only when the
# site has a database.
DB_PASSWORD=generated_per_site

View file

@ -0,0 +1,59 @@
# Rendered by the provisioning CLI (Phase 4) into deployments/<customer>/<site>/.
# Render context is documented in site-templates/README.md.
name: {{ slug }}
services:
nginx:
image: {{ nginx_image }}
container_name: {{ slug }}_nginx
restart: unless-stopped
depends_on: [fpm]
volumes:
- {{ webroot }}:/var/www/html:ro
- ./nginx-site.conf:/etc/nginx/conf.d/default.conf:ro
networks: [proxy, site]
labels:
- "traefik.enable=true"
- "traefik.http.routers.{{ slug }}.rule=Host(`{{ domains | join('`) || Host(`') }}`)"
- "traefik.http.routers.{{ slug }}.entrypoints=websecure"
- "traefik.http.routers.{{ slug }}.tls.certresolver=le"
- "traefik.http.routers.{{ slug }}.middlewares=security-headers@file"
- "traefik.http.services.{{ slug }}.loadbalancer.server.port=80"
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
fpm:
image: {{ php_image }}
container_name: {{ slug }}_fpm
restart: unless-stopped
volumes:
- {{ webroot }}:/var/www/html
{% if database %}
env_file: [.env]
environment:
DB_HOST: mariadb
DB_NAME: db_{{ slug_underscored }}
DB_USER: u_{{ slug_underscored }}
# DB_PASSWORD is injected from .env
networks: [site, platform]
{% else %}
networks: [site]
{% endif %}
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
networks:
proxy:
external: true
{% if database %}
platform:
external: true
{% endif %}
site:
name: {{ slug }}_net

View file

@ -0,0 +1,16 @@
server {
listen 80 default_server;
root /var/www/html;
index index.php index.html;
include /etc/nginx/snippets/security.conf;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include /etc/nginx/snippets/fastcgi-php.conf;
fastcgi_pass fpm:9000;
}
}

View file

@ -0,0 +1,28 @@
# Rendered by the provisioning CLI (Phase 4). Redirect-only site: a tiny nginx
# that returns a permanent (or temporary) redirect. No PHP, no DB, no web root.
name: {{ slug }}
services:
nginx:
image: {{ nginx_image }}
container_name: {{ slug }}_nginx
restart: unless-stopped
volumes:
- ./nginx-redirect.conf:/etc/nginx/conf.d/default.conf:ro
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.{{ slug }}.rule=Host(`{{ domains | join('`) || Host(`') }}`)"
- "traefik.http.routers.{{ slug }}.entrypoints=websecure"
- "traefik.http.routers.{{ slug }}.tls.certresolver=le"
- "traefik.http.routers.{{ slug }}.middlewares=security-headers@file"
- "traefik.http.services.{{ slug }}.loadbalancer.server.port=80"
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
networks:
proxy:
external: true

View file

@ -0,0 +1,7 @@
server {
listen 80 default_server;
server_tokens off;
# {{ redirect_code | default(301) }} redirect, preserving the request path.
return {{ redirect_code | default(301) }} {{ redirect_to }}$request_uri;
}

View file

@ -0,0 +1,29 @@
# Rendered by the provisioning CLI (Phase 4). Static site: nginx only, no PHP,
# no DB. Web root served read-only.
name: {{ slug }}
services:
nginx:
image: {{ nginx_image }}
container_name: {{ slug }}_nginx
restart: unless-stopped
volumes:
- {{ webroot }}:/var/www/html:ro
- ./nginx-site.conf:/etc/nginx/conf.d/default.conf:ro
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.{{ slug }}.rule=Host(`{{ domains | join('`) || Host(`') }}`)"
- "traefik.http.routers.{{ slug }}.entrypoints=websecure"
- "traefik.http.routers.{{ slug }}.tls.certresolver=le"
- "traefik.http.routers.{{ slug }}.middlewares=security-headers@file"
- "traefik.http.services.{{ slug }}.loadbalancer.server.port=80"
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
networks:
proxy:
external: true

View file

@ -0,0 +1,16 @@
server {
listen 80 default_server;
root /var/www/html;
index index.html;
include /etc/nginx/snippets/security.conf;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
access_log off;
}
}

View file

@ -0,0 +1,3 @@
# Generated per-site by the CLI (Phase 4) into deployments/<customer>/<site>/.env
# (git-ignored; real value comes from secrets.enc.yaml).
WORDPRESS_DB_PASSWORD=generated_per_site

View file

@ -0,0 +1,53 @@
# Rendered by the provisioning CLI (Phase 4). WordPress always has a database.
# Uses the official WordPress php-fpm image; nginx serves it and proxies PHP.
name: {{ slug }}
services:
nginx:
image: {{ nginx_image }}
container_name: {{ slug }}_nginx
restart: unless-stopped
depends_on: [fpm]
volumes:
- {{ webroot }}:/var/www/html:ro
- ./nginx-site.conf:/etc/nginx/conf.d/default.conf:ro
networks: [proxy, site]
labels:
- "traefik.enable=true"
- "traefik.http.routers.{{ slug }}.rule=Host(`{{ domains | join('`) || Host(`') }}`)"
- "traefik.http.routers.{{ slug }}.entrypoints=websecure"
- "traefik.http.routers.{{ slug }}.tls.certresolver=le"
- "traefik.http.routers.{{ slug }}.middlewares=security-headers@file"
- "traefik.http.services.{{ slug }}.loadbalancer.server.port=80"
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
fpm:
image: {{ wordpress_image }}
container_name: {{ slug }}_fpm
restart: unless-stopped
env_file: [.env]
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_NAME: db_{{ slug_underscored }}
WORDPRESS_DB_USER: u_{{ slug_underscored }}
# WORDPRESS_DB_PASSWORD is injected from .env
volumes:
- {{ webroot }}:/var/www/html
networks: [site, platform]
deploy:
resources:
limits:
cpus: "{{ resources.cpu }}"
memory: {{ resources.memory }}
networks:
proxy:
external: true
platform:
external: true
site:
name: {{ slug }}_net

View file

@ -0,0 +1,31 @@
server {
listen 80 default_server;
root /var/www/html;
index index.php;
include /etc/nginx/snippets/security.conf;
# WordPress pretty permalinks.
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include /etc/nginx/snippets/fastcgi-php.conf;
fastcgi_pass fpm:9000;
}
# Never execute PHP uploaded into wp-content/uploads (blunts upload RCE).
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}
# Reduce attack surface / noise.
location = /xmlrpc.php { deny all; }
# Long-cache static assets.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
access_log off;
}
}