This is part 4 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 3 covers the contact form security.


One of the goals for holas.pl was a development environment as simple as the production one. No database to start, no Docker networking to configure by hand, no five-minute startup sequence. The result is a DDEV-based dev setup that starts with a single command and a production stack that runs in two containers.

Development with DDEV

DDEV handles the local development environment. The entire setup is in .ddev/config.yaml:

name: holas-pl
type: php
php_version: "8.5"
webserver_type: nginx-fpm
nodejs_version: "22"
omit_containers: [db]
web_environment:
  - APP_ENV=dev

The omit_containers: [db] line is notable — there's no database, so there's no database container. DDEV's default MySQL/MariaDB setup is skipped entirely. ddev start spins up nginx + PHP-FPM and nothing else.

In development the content tree is rebuilt on every request, so editing a Markdown file and refreshing the browser shows the change immediately. No cache to clear. Symfony's debug toolbar is available. Draft posts are visible.

The Build Command

ddev build runs the full pipeline:

rm -rf var/dart-sass         # remove arch-specific binary (see below)
php bin/console cache:clear
php bin/console sass:build   # compile SCSS via dart-sass
php bin/console asset-map:compile  # fingerprint assets
php bin/console app:build    # render all URLs to public/static/
npx pagefind --site public/static --output-path public/pagefind

After this, public/static/ contains the complete site as HTML files. nginx serves from that directory. The dart-sass step removes the platform-specific binary before the build to force a fresh download — more on this below.

Code Quality Checks

ddev code-check runs four checks in sequence:

composer validate --strict
composer audit --no-dev         # checks for known CVEs in dependencies
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse      # level 6

ddev code-fix auto-fixes PHP CS Fixer issues and re-runs the check. PHPStan level 6 catches missing type hints, wrong argument types, and unknown methods before they reach production.

The Styleguide

A dev-only page at /styleguide/ documents every component in the UI using the actual CSS classes — not wrappers or snapshots. The page is served only in the dev environment (the controller throws a 404 in production) and is not included in the static build.

The value of the styleguide is in development: changing a component's SCSS immediately updates the styleguide. There's no separate design system to keep in sync.

Asset Pipeline Without Node.js

SCSS is compiled by symfonycasts/sass-bundle, which wraps dart-sass and requires no Node.js installation. The single entrypoint assets/styles/app.scss imports all partials. In development it compiles on-the-fly. In production php bin/console sass:build runs before the build.

JavaScript modules are handled by Symfony's AssetMapper — no webpack, no Vite, no rollup. Scripts are loaded as plain <script src="..."> tags with content-hashed filenames. The import map registers only the main app.js entrypoint; other scripts (contact form, search, cookie banner) are loaded separately via {{ asset('script.js') }} in templates.

The dart-sass binary problem: symfonycasts/sass-bundle downloads a platform-specific dart-sass binary to var/dart-sass/. The binary compiled on a development machine (x86_64) won't run in the production Docker container (also x86_64 in this case, but the binary path and version can differ). The solution is straightforward: delete the binary before every build and let dart-sass download the correct one for the current platform.

Production: Two Containers

The production docker-compose.yaml:

services:
  nginx:
    image: nginx:alpine
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - .:/app:ro
    depends_on:
      - php

  php:
    build:
      context: .
      dockerfile: docker/Dockerfile
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - .:/app

Two containers: nginx and PHP-FPM. No database container. nginx mounts the project read-only; PHP-FPM runs as the host user to avoid file permission issues with the Symfony cache.

The PHP image (docker/Dockerfile) is php:8.5-fpm-alpine with only what's needed: icu-dev (Symfony intl), nodejs and npm (for the Pagefind build step), unzip, git, and Composer.

Deployment

The entire deployment is a single script ./deploy.sh --prod:

  1. docker compose down
  2. docker compose build --pull — rebuilds the PHP image from scratch
  3. docker compose up -d
  4. composer install --no-dev --optimize-autoloader
  5. php bin/console cache:clear
  6. rm -rf var/dart-sass — remove the arch-specific binary
  7. php bin/console sass:build
  8. php bin/console asset-map:compile
  9. php bin/console app:build — render all static HTML
  10. npx --yes pagefind --site public/static --output-path public/pagefind

The build runs inside the PHP container where the correct CPU architecture is known. After step 10, nginx is already serving the previous build's static files. The new files replace them atomically at the filesystem level. There's a brief window where a partial build is live, but for a low-traffic portfolio site this is acceptable without blue-green deployment complexity.

No CI/CD pipeline, no staging environment. Deployment is ssh server, cd holas.pl, git pull, ./deploy.sh --prod.

Compared to WordPress

The full WordPress production stack required: PHP-FPM, MySQL, a caching layer (Redis or filesystem), a scheduled task runner for wp-cron, and enough RAM to keep MySQL warm. Updates to any component required downtime or careful sequencing.

The current stack is two containers. A Raspberry Pi 5 runs it without memory pressure. Deployment is a shell script. The entire codebase, content, and configuration fits in a single git repository. Backup is git push.

The next post in the series covers the most unconventional part of this project: building the entire site with Claude Code as an AI pair programmer, and using MCP tools to generate featured images locally.