A small self-hosted infrastructure built from scratch with Docker and Docker Compose: an HTTPS WordPress site backed by PHP-FPM and MariaDB, behind an NGINX reverse proxy, with each service in its own container built from a custom Alpine image. Built as part of the 42 curriculum.
Three core services on a single isolated bridge network. Only NGINX is exposed to the host; WordPress and the database are reachable only from inside the network.
host :443 (TLS)
│
┌───────▼───────┐
│ nginx │ reverse proxy, TLS termination (TLSv1.2/1.3)
│ (Alpine) │ serves static, proxies *.php to PHP-FPM
└───────┬───────┘
│ fastcgi
┌───────▼───────┐
│ wordpress │ PHP-FPM + WP-CLI, runs as non-root user
│ (Alpine) │
└───────┬───────┘
│ mysql :3306
┌───────▼───────┐
│ mariadb │ database, not exposed to host
│ (Alpine) │
└───────────────┘
network: inception (bridge, static subnet)
volumes: wordpress files + mariadb data (bind-mounted to host)
secrets: credentials mounted at /run/secrets/* (never baked into images)
Bonus services (Redis, Adminer, FTP, a static site, MailHog) attach to the same network.
All eight containers running, with MariaDB reporting healthy:
Five additional services run on the same inception network, each in its own custom Alpine container:
- Redis – object cache for WordPress, reducing database load on repeated page renders. Reachable only inside the network (port 6379, not exposed to the host); password supplied via Docker secret.
- Adminer – lightweight web database client for inspecting MariaDB (port 8080).
- FTP (vsftpd) – file access to the WordPress volume over FTP with a passive-port range (21 + 21000–21010).
- Static site – a small standalone site served independently of WordPress (port 8081), demonstrating a second web service on the same network.
- MailHog – SMTP catch-all that intercepts mail sent by WordPress and exposes a web inbox (UI on 8025, SMTP on 1025), so outgoing mail can be tested without a real mail server.
These follow the same conventions as the core services: custom Alpine images, secrets for credentials, no host exposure unless a UI/protocol requires it.
Secrets as files, not environment variables. All credentials are injected via Docker secrets and read from /run/secrets/<name> at runtime. Environment variables leak through docker inspect, logs, and child processes; secrets are mounted per-service as files with standard filesystem permissions, so access is scoped and auditable. No password is ever written into an image layer or the compose file.
Ordered, health-gated startup. MariaDB defines a healthcheck (a real mariadb-admin ping), and WordPress declares depends_on: condition: service_healthy. This avoids the classic race where the app starts before the database is actually accepting connections – a "started" container is not the same as a "ready" one.
Least privilege. The WordPress container creates a dedicated non-root user and runs PHP-FPM as that user. The database is never published to the host; only NGINX's 443 is exposed.
Idempotent entrypoints. Each entrypoint checks state before acting (if ! wp core is-installed, if [ ! -d /var/lib/mysql/mysql ]), so containers can stop and restart without re-bootstrapping or corrupting data. Scripts run with set -e to fail fast.
Reproducible, minimal images. Every image is built from a pinned alpine:3.22 base with only the packages each service needs, keeping images small and the attack surface low. No pre-built application images are pulled.
Persistent state via bind-mounted volumes. WordPress files and MariaDB data live in named volumes bound to ${HOME}/data/... on the host, so data survives container and image removal.
Requires Docker and Docker Compose. Configuration is provided through srcs/.env (see srcs/.env.example) and a set of secret files under secrets/:
secrets/
├─ mariadb_root_password.txt
├─ mariadb_user_name.txt
├─ mariadb_user_password.txt
├─ wordpress_admin_name.txt
├─ wordpress_admin_password.txt
├─ wordpress_admin_email.txt
├─ wordpress_user_name.txt
├─ wordpress_user_password.txt
└─ wordpress_user_email.txt
# bonus services
├─ redis_password.txt
├─ ftp_user_name.txt
├─ ftp_user_password.txt
├─ adminer_user_name.txt
└─ adminer_user_password.txt
Then, from the project root:
make # build images and start all containers (creates ${HOME}/data dirs)
make ps # list running containers
make stop # stop containers
make start # start stopped containers
make rm # remove stopped containers
make clean # full teardown: remove containers, networks, images, volumes, and the host data dirsMap the domain to localhost by adding it to /etc/hosts (matching DOMAIN in your .env):
127.0.0.1 mkugan.42.fr
The site is then served at https://<DOMAIN> and the admin area at https://<DOMAIN>/wp-admin (the TLS certificate is self-signed, so a browser warning is expected in local use).
This project is where Docker fundamentals clicked for me: the difference between images and containers, how namespaces and cgroups isolate processes, container networking and DNS, volume persistence, and the security trade-offs between secrets and environment variables. The natural next step is moving from single-host Docker Compose to orchestration – running the same multi-service topology on Kubernetes (deployments, services, secrets, health/readiness probes), where the startup-ordering and least-privilege ideas here map directly onto liveness/readiness probes and pod security.
AI usage: AI assisted with the initial plan of work (topics to research, ideas to test), proofreading documentation, and debugging.




