diff --git a/.forgejo/workflows/images.yml b/.forgejo/workflows/images.yml new file mode 100644 index 0000000..3de7666 --- /dev/null +++ b/.forgejo/workflows/images.yml @@ -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 diff --git a/site-templates/README.md b/site-templates/README.md index 19feb93..3f067ea 100644 --- a/site-templates/README.md +++ b/site-templates/README.md @@ -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///`. 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:-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:-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 (`_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` | `-`; 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. diff --git a/site-templates/images/build.sh b/site-templates/images/build.sh new file mode 100755 index 0000000..459268b --- /dev/null +++ b/site-templates/images/build.sh @@ -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." diff --git a/site-templates/images/nginx/Dockerfile b/site-templates/images/nginx/Dockerfile new file mode 100644 index 0000000..c21ee0a --- /dev/null +++ b/site-templates/images/nginx/Dockerfile @@ -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 diff --git a/site-templates/images/nginx/conf/nginx.conf b/site-templates/images/nginx/conf/nginx.conf new file mode 100644 index 0000000..d1f6297 --- /dev/null +++ b/site-templates/images/nginx/conf/nginx.conf @@ -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; +} diff --git a/site-templates/images/nginx/conf/snippets/fastcgi-php.conf b/site-templates/images/nginx/conf/snippets/fastcgi-php.conf new file mode 100644 index 0000000..949b40e --- /dev/null +++ b/site-templates/images/nginx/conf/snippets/fastcgi-php.conf @@ -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; diff --git a/site-templates/images/nginx/conf/snippets/security.conf b/site-templates/images/nginx/conf/snippets/security.conf new file mode 100644 index 0000000..f2da7ea --- /dev/null +++ b/site-templates/images/nginx/conf/snippets/security.conf @@ -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; +} diff --git a/site-templates/images/php-fpm/Dockerfile b/site-templates/images/php-fpm/Dockerfile new file mode 100644 index 0000000..b69a558 --- /dev/null +++ b/site-templates/images/php-fpm/Dockerfile @@ -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 diff --git a/site-templates/images/php-fpm/php.ini b/site-templates/images/php-fpm/php.ini new file mode 100644 index 0000000..e56460e --- /dev/null +++ b/site-templates/images/php-fpm/php.ini @@ -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 diff --git a/site-templates/images/php-fpm/www.conf b/site-templates/images/php-fpm/www.conf new file mode 100644 index 0000000..9847ac9 --- /dev/null +++ b/site-templates/images/php-fpm/www.conf @@ -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 diff --git a/site-templates/profiles/custom-php/.env.example b/site-templates/profiles/custom-php/.env.example new file mode 100644 index 0000000..53f7f3a --- /dev/null +++ b/site-templates/profiles/custom-php/.env.example @@ -0,0 +1,4 @@ +# Generated per-site by the CLI (Phase 4) into deployments///.env +# (git-ignored; real value comes from secrets.enc.yaml). Present only when the +# site has a database. +DB_PASSWORD=generated_per_site diff --git a/site-templates/profiles/custom-php/docker-compose.yml.j2 b/site-templates/profiles/custom-php/docker-compose.yml.j2 new file mode 100644 index 0000000..d759ae9 --- /dev/null +++ b/site-templates/profiles/custom-php/docker-compose.yml.j2 @@ -0,0 +1,59 @@ +# Rendered by the provisioning CLI (Phase 4) into deployments///. +# 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 diff --git a/site-templates/profiles/custom-php/nginx-site.conf.j2 b/site-templates/profiles/custom-php/nginx-site.conf.j2 new file mode 100644 index 0000000..2bf3220 --- /dev/null +++ b/site-templates/profiles/custom-php/nginx-site.conf.j2 @@ -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; + } +} diff --git a/site-templates/profiles/redirect/docker-compose.yml.j2 b/site-templates/profiles/redirect/docker-compose.yml.j2 new file mode 100644 index 0000000..9e81bc8 --- /dev/null +++ b/site-templates/profiles/redirect/docker-compose.yml.j2 @@ -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 diff --git a/site-templates/profiles/redirect/nginx-redirect.conf.j2 b/site-templates/profiles/redirect/nginx-redirect.conf.j2 new file mode 100644 index 0000000..aea1ad3 --- /dev/null +++ b/site-templates/profiles/redirect/nginx-redirect.conf.j2 @@ -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; +} diff --git a/site-templates/profiles/static/docker-compose.yml.j2 b/site-templates/profiles/static/docker-compose.yml.j2 new file mode 100644 index 0000000..f58d7f1 --- /dev/null +++ b/site-templates/profiles/static/docker-compose.yml.j2 @@ -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 diff --git a/site-templates/profiles/static/nginx-site.conf.j2 b/site-templates/profiles/static/nginx-site.conf.j2 new file mode 100644 index 0000000..d8c4f71 --- /dev/null +++ b/site-templates/profiles/static/nginx-site.conf.j2 @@ -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; + } +} diff --git a/site-templates/profiles/wordpress/.env.example b/site-templates/profiles/wordpress/.env.example new file mode 100644 index 0000000..090c3a7 --- /dev/null +++ b/site-templates/profiles/wordpress/.env.example @@ -0,0 +1,3 @@ +# Generated per-site by the CLI (Phase 4) into deployments///.env +# (git-ignored; real value comes from secrets.enc.yaml). +WORDPRESS_DB_PASSWORD=generated_per_site diff --git a/site-templates/profiles/wordpress/docker-compose.yml.j2 b/site-templates/profiles/wordpress/docker-compose.yml.j2 new file mode 100644 index 0000000..d405c61 --- /dev/null +++ b/site-templates/profiles/wordpress/docker-compose.yml.j2 @@ -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 diff --git a/site-templates/profiles/wordpress/nginx-site.conf.j2 b/site-templates/profiles/wordpress/nginx-site.conf.j2 new file mode 100644 index 0000000..0132e57 --- /dev/null +++ b/site-templates/profiles/wordpress/nginx-site.conf.j2 @@ -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; + } +}