Projects

Retiring the WordPress VPS I Built to Learn

A retrospective on the Docker-based WordPress platform I began building at university in 2021: how its security architecture evolved through experimentation, what failed in practice and what several years of operating it taught me.

  • Docker
  • Docker Compose
  • Caddy
  • WordPress
  • PHP-FPM
  • MySQL
  • phpMyAdmin
  • Trilium
  • Duplicati
  • Suricata
  • SSH
On this page

Project snapshot

Authorised scope
Personal infrastructure I designed and operated on my own VPS from 2021 until retiring the WordPress platform in 2026.
My contribution
I built and evolved the Docker Compose architecture, Caddy routing, separated PHP and database services, private administration paths, authentication controls and off-site backup workflow, then migrated the portfolio away from it.
Technical focus
  • Docker Compose
  • Caddy and TLS
  • WordPress and PHP-FPM
  • Network segmentation
  • SSH-only administration
  • Backup and recovery
Demonstrated outcome
The platform hosted my portfolio for several years and gave me sustained operational experience. Recurring availability failures, maintenance friction and architectural conflicts ultimately made retirement the better engineering decision.

How the project began

I first built this infrastructure in 2021, while I was in my second year at university. It did not begin as a production architecture designed from a complete set of requirements; it began as a way to learn.

I wanted somewhere to host my website, but I also wanted an environment where I could experiment with Docker, reverse proxies, TLS, PHP, databases, network segmentation, authentication, backups and web application security. As I learned something new, I tried to apply it. Sometimes it improved the system; sometimes it solved one problem while creating another; and sometimes it failed completely and taught me more than the successful changes did.

I continued building and iterating on the VPS for several years. What began as a relatively ordinary WordPress installation eventually developed into a small Docker-based application platform containing Caddy, a custom PHP-FPM runtime, MySQL, phpMyAdmin, Trilium and a separate off-site backup stack. I added internal networks, restricted PHP connectivity, two-factor authentication, a web application firewall, SSH port forwarding and other controls intended to reduce the risks of running WordPress publicly.

There was a real security architecture behind it, but there were also wrong assumptions, incomplete controls and operational problems. The most obvious was that the website would periodically stop working. Roughly every couple of weeks, I had to connect to the VPS over SSH and restart the entire Compose project:

sudo docker compose down && sudo docker compose up -d

That usually brought the website back, but it neither explained the failure nor made the system reliable. Updates were awkward because the PHP runtime was deliberately isolated, while plugins and themes introduced a continuous maintenance burden. I also spent time trying to fit Suricata around Caddy, but the traffic path and the hardware available on the VPS made it impractical. WordPress itself was also becoming increasingly restrictive as a design platform.

I am publishing this as I retire the project and move away from its old operating model. This is not a modern deployment guide or an example of how the same system should be built today. It is a record of how the architecture developed, why I made the decisions I did, what happened when those decisions met reality and what I learned from the parts that failed.

Failure was part of what the system was for, and much of what I gained from the project came from understanding where and why it failed.

Why publish the failures?

Cybersecurity has a habit of presenting learning as a collection of finished results. People publish the exploit that worked, the detection that fired, the architecture diagram after it was cleaned up and the solution after the difficult parts were removed. The hours spent misunderstanding the problem, breaking the environment, following the wrong approach and rebuilding everything are usually missing.

That creates a false picture of technical work. The final result might represent two per cent of the time spent on a project. The other ninety-eight per cent is reading, testing, failing, troubleshooting, asking for help, questioning assumptions and occasionally wondering why something that worked yesterday has suddenly fallen over.

“Try harder” is easy advice to give once the answer is already known. It is also easy to hide several years of mistakes and present only the final working result as though the route towards it had been straightforward.

Most technical ability is developed in the part people are less willing to show: the struggle before the result. This post covers that part: the controls that worked, the assumptions that did not and the mistakes I only understood after operating the system.

How the architecture developed

The final Docker Compose files make the architecture look like one complete design, but it grew gradually. I introduced each component because I had learned something new, encountered a limitation in the existing design or wanted to test whether an idea would work in practice.

Starting with Docker

The first component was Docker. When I began the project, I was still developing my understanding of Linux administration, web infrastructure and containerisation.

Docker gave me a practical way to run services without installing every application dependency directly onto the VPS. It also provided a clear way to start, stop and recreate services while keeping persistent data outside the containers themselves. At that stage, the objective was straightforward:

Run a website and learn how containerised services fit together.

The system did not begin as a security project. It began with my curiosity about Docker and its growing appeal to developers, systems administrators and other IT professionals. I later applied the skills and knowledge I gained from experimenting with Docker throughout my work.

Adding Caddy for TLS

Caddy came next. I wanted the website to use HTTPS, but I did not want certificate issuance and renewal to become a repetitive manual task. Caddy handled TLS automatically and provided a straightforward configuration format for serving and reverse proxying web traffic.

The initial architecture was simple:

Internet
   |
 Caddy
   |
Website

Caddy listened on ports 80 and 443, handled TLS and forwarded requests to the application. As the system became more complicated, Caddy remained at the centre of it.

Caddy was never the part of this architecture I disliked. It was consistently one of the cleanest components in the system: the configuration was easy to understand, reverse proxying was simple and automatic certificate management removed a significant amount of unnecessary work. The same characteristics also make Caddy useful when rapidly building temporary infrastructure for labs, security testing and adversary simulation.

Running the official WordPress container

The first WordPress deployment used the official Docker image. At the time, that was the obvious choice: the image already contained WordPress and the PHP runtime, so I could connect it to a separate MySQL container through Docker Compose and place Caddy in front of it.

The early architecture looked roughly like this:

Internet
   |
 Caddy
   |
Official WordPress container
   |
 MySQL

This gave me a functioning content-management system without requiring me to build the complete PHP environment myself, but it also hid some of the internal structure. WordPress, PHP execution and request handling were bundled into an image that I initially treated as a single service.

I became less comfortable with that arrangement as I learned more about how WordPress compromises worked.

How CTFs changed the threat model

While completing CTFs and web security exercises, I repeatedly encountered vulnerable WordPress installations, malicious plugins, arbitrary file uploads, PHP web shells and reverse-shell payloads. WordPress is not automatically a web shell, but its architecture makes the risk easy to understand from an attacker’s perspective.

It is a public PHP application with a writable application directory, an administration interface and an enormous plugin ecosystem. If an attacker obtains the ability to upload or write PHP into an executable location, the server can run it.

The path I repeatedly encountered in labs looked something like this:

WordPress or plugin weakness
            |
Administrative access, file upload
or arbitrary code execution
            |
        PHP web shell
            |
Reverse shell or further compromise

Seeing that pattern repeatedly changed how I thought about my own website. I did not want a compromised PHP process to have unrestricted access to the rest of the system or a normal route back out to the internet. That led me to separate code execution from the public web server and restrict what the PHP process could reach.

This was where the project began moving away from a standard WordPress deployment and towards a more experimental design.

Breaking apart the WordPress container

When I later migrated the VPS from one hosting provider to another, I used the move as an opportunity to restructure the application. Rather than continuing with the official WordPress image, I separated the application files, web server and PHP runtime.

The WordPress files were stored directly on the VPS and mounted into both Caddy and a custom PHP-FPM container. Calling it a custom image may make it sound more substantial than it was. The Dockerfile was deliberately minimal:

FROM php:fpm

# Update and install required packages
RUN docker-php-ext-install mysqli && \
    docker-php-ext-enable mysqli

The image used the standard PHP-FPM base and added the mysqli extension required for PHP to communicate with MySQL. The separation came from running PHP-FPM as its own service, with controlled filesystem mounts and Docker network access.

The resulting Compose service looked like this:

php-fpm:
  image: php-fpm-custom
  volumes:
    - ./wordpress_html:/var/www/html
    - ./php.ini:/usr/local/etc/php/php.ini
    - ./www.conf:/usr/local/etc/php-fpm.d/www.conf
  networks:
    - private-website

Caddy mounted the same WordPress directory:

volumes:
  - ./wordpress_html:/var/www/html

Caddy served the files that did not require PHP execution, including images, stylesheets, JavaScript and other static assets. Requests requiring PHP were forwarded to PHP-FPM:

root * /var/www/html
php_fastcgi php-fpm:9000
encode zstd gzip
file_server

The architecture became:

                        WordPress files
                         /            \
                        /              \
                     Caddy          PHP-FPM
                        \              /
                         \            /
                             MySQL

This gave me more direct control over where PHP executed and allowed me to place PHP-FPM on a restricted internal network rather than giving the complete WordPress container ordinary external connectivity.

Isolating the PHP execution environment

The PHP-FPM container was attached only to the internal website network. A simplified network definition looked like this:

networks:
  public-services:
    internal: false

  private-trilium:
    internal: true

  private-website:
    internal: true

PHP-FPM, MySQL and Caddy shared private-website. Caddy also joined the externally connected network because it needed to accept public traffic. PHP-FPM did not.

The intention was to limit post-exploitation behaviour. If an attacker gained remote code execution through WordPress, a plugin or the PHP runtime, I wanted to make a conventional outbound reverse-shell connection more difficult. PHP could communicate with Caddy and MySQL because the application required those connections, but it did not have the same external route as the public-facing services.

This was a deliberate security decision based directly on what I had been seeing in CTF environments, although it was not complete containment.

The PHP-FPM container still had read and write access to the WordPress directory, could communicate with MySQL and could receive requests forwarded by Caddy before returning information through HTTP responses.

A compromised PHP process could potentially:

  • read the WordPress configuration;
  • obtain database credentials;
  • communicate with MySQL;
  • modify WordPress content;
  • create or alter user accounts;
  • write additional PHP files;
  • establish persistence through a web shell;
  • receive instructions through normal HTTP requests;
  • return stolen information through HTTP responses.

The restriction removed some attacker options, particularly a straightforward direct callback. In practice, PHP-FPM was constrained rather than fully contained because it still had access to the files, database and HTTP paths WordPress needed in order to run.

At the time, I was less concerned about destructive changes because the application data was backed up regularly to external storage. Those backups would not have prevented compromise, credential theft or unauthorised access, but they gave me a recovery path if the website or its data was damaged.

The MySQL service

MySQL ran as a separate container connected only to the private website network. The relevant service configuration looked like this:

database:
  image: mysql
  restart: always
  environment:
    MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    MYSQL_DATABASE: wordpress
    MYSQL_USER: wordpress
    MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
  volumes:
    - ./database:/var/lib/mysql
  networks:
    - private-website

The container did not publish a MySQL port through the VPS. WordPress reached the database through Docker networking using the service name; MySQL had no reason to accept connections from the public network.

Keeping phpMyAdmin off the public interface

phpMyAdmin was useful when I needed to inspect or administer the WordPress database, but it had no reason to be publicly exposed.

Its port was therefore bound only to the VPS loopback address:

phpmyadmin:
  image: phpmyadmin
  restart: always
  ports:
    - "127.0.0.1:8080:80"
  environment:
    PMA_HOST: database
  networks:
    - private-website
    - public-services

A connection to the public address of the VPS would not reach phpMyAdmin. When I needed to use it, I accessed it through SSH local port forwarding:

ssh -L 8080:127.0.0.1:8080 user@server

I could then open the following address on my own machine:

http://127.0.0.1:8080

The browser connected to my local port, and the traffic travelled through the encrypted SSH connection to the loopback interface on the VPS. This allowed me to use the web interface without placing it on the public internet.

SSH password authentication was disabled. Access required a private SSH key, and the key itself was protected with a passphrase. Possession of the key file alone was not enough; the key still had to be unlocked with its passphrase.

The administrative path was:

Local machine
     |
Passphrase-protected SSH key
     |
Encrypted SSH session
     |
Local port forwarding
     |
VPS loopback interface
     |
phpMyAdmin

This kept phpMyAdmin available for maintenance without publishing it on the VPS’s public interface.

Two-factor authentication and the WAF

The WordPress administration interface was protected with two-factor authentication, and I integrated a web application firewall to add another layer between unsolicited traffic and the application.

Combined with the wider architecture, the WordPress deployment had:

  • two-factor authentication;
  • a web application firewall;
  • a separate PHP-FPM runtime;
  • restricted PHP networking;
  • an internal MySQL network;
  • no publicly exposed database port;
  • loopback-only phpMyAdmin access;
  • Caddy as the central ingress point;
  • off-site backups.

Each control addressed a specific concern, but WordPress remained a publicly reachable PHP application backed by a database and extended through plugins and themes. The underlying application still required continuous patching, testing and maintenance.

Attempting to restrict direct-origin access

The Caddy configuration also attempted to distinguish requests that had passed through an upstream proxy from requests sent directly to the VPS. The implementation checked for the presence of a forwarding header normally added by that proxy.

The intention was to reject requests that appeared to have bypassed the expected route. Conceptually, the configuration looked like this:

@trustedProxyRequest {
    header <forwarded-client-address-header> *
}

handle @trustedProxyRequest {
    root * /var/www/html
    php_fastcgi php-fpm:9000
    encode zstd gzip
    file_server
}

handle {
    respond "Access denied" 403
}

The implementation only established that the request contained the expected header; someone connecting directly to the origin could provide the same value manually.

The rule enforced:

Requests must contain a header normally added by the proxy.

What I actually wanted was:

Requests must originate from the proxy.

I had identified direct-origin access as something I wanted to restrict because Cloudflare was providing network and WAF protections in front of the site. The mistake was trusting a request header as proof that the connection had actually passed through Cloudflare. It was a textbook lesson in not trusting user-controlled input: an attacker could connect directly to the origin and add the expected header using Burp Suite.

Adding Trilium

The VPS later took on another role when I added Trilium for my notes. Trilium did not provide the TLS setup I wanted, but Caddy was already handling HTTPS and could do the same for another internal application.

Trilium ran inside its own container and was connected to a separate internal Docker network. A simplified service definition looked like this:

trilium:
  image: triliumnext/notes
  restart: always
  volumes:
    - ./trilium-data:/home/node/trilium-data
    - /etc/timezone:/etc/timezone:ro
    - /etc/localtime:/etc/localtime:ro
  networks:
    - private-trilium

The container did not need to publish its application port through the VPS. Caddy reached it over the private Docker network and handled TLS at the edge.

The pattern became:

                         Caddy
                       /       \
                      /         \
              WordPress       Trilium
                  |               |
              PHP-FPM       Persistent data
                  |
                MySQL

Adding Trilium meant the VPS was no longer only hosting WordPress. It had become a shared platform supporting both the website and a stateful application behind the same Caddy instance. That made the availability of the VPS and the reliability of the backup process more important, because a failure now affected more than the website alone.

Adding Duplicati for off-site backups

Once the VPS held both the website and Trilium data, backups became more important. I introduced Duplicati to collect the persistent data and upload backup sets to external storage.

Duplicati originally ran inside the same Compose project as WordPress. At that stage, I had not persisted Duplicati’s /config directory correctly, so recreating the WordPress stack also recreated Duplicati without the saved jobs and settings I expected to retain.

It was a silly mistake: I had treated the container as disposable without persisting the data that defined the backup jobs. I fixed it by mounting /config to a named volume and separating Duplicati into its own Compose project, so restarting WordPress no longer affected the backup service.

The final service definition was:

volumes:
  duplicatiConfig:

services:
  duplicati:
    image: duplicati/duplicati
    restart: always
    ports:
      - "127.0.0.1:8200:8200"
    volumes:
      - ../site/trilium-data:/home/trilium:ro
      - duplicatiConfig:/config
      - ../site/duplicatiBackup:/Backup
      - ../site/wordpress_html:/wordpress:ro
      - ../site/database:/database_site:ro

The source application directories were mounted read-only. Duplicati could read the WordPress files, database directory and Trilium data, but it could not modify the systems it was protecting.

Duplicati stored its own configuration in a named volume, while the backup sets were uploaded away from the VPS. Losing the server would therefore not also remove the only backup copy.

Restricting the Duplicati interface

Like phpMyAdmin, Duplicati provided a powerful administrative web interface. It was also bound only to the loopback address:

ports:
  - "127.0.0.1:8200:8200"

I accessed it through SSH port forwarding rather than exposing it publicly:

ssh -L 8200:127.0.0.1:8200 user@server

The interface could then be reached locally at:

http://127.0.0.1:8200

The same SSH configuration protected this administrative path: password authentication was disabled, key-based authentication was required, the private key was protected with a passphrase and the web interface was bound only to localhost.

I could still reach the interface when I needed it, but it was not listening on the public interface of the VPS.

Backing up the database files

Duplicati read the live MySQL data directory directly:

- ../site/database:/database_site:ro

This gave me an external copy of the database directory, although MySQL could be writing to those files while the backup ran. I accepted that limitation at the time rather than building a separate database export process.

The final architecture

By the end, the environment contained:

  • Docker as the base for running and separating services;
  • Caddy for TLS, public ingress and reverse proxying;
  • WordPress files mounted into Caddy and PHP-FPM;
  • a custom PHP execution environment;
  • restricted outbound connectivity from PHP-FPM;
  • MySQL on an internal network;
  • phpMyAdmin restricted to loopback;
  • two-factor authentication on WordPress;
  • a web application firewall;
  • Trilium behind Caddy;
  • Duplicati with read-only access to application data;
  • off-site backups;
  • SSH key authentication;
  • a passphrase-protected private key;
  • SSH port forwarding for administrative interfaces.

None of that appeared at once. Docker gave me a way to learn containerisation, Caddy gave me manageable HTTPS and the official WordPress image gave me an easy starting point. The CTFs I was completing later changed how I thought about PHP execution and WordPress compromise, leading me to separate PHP-FPM.

I added Trilium because Caddy could provide routing and TLS for another internal application, and Duplicati because the data on the VPS had become important enough to back up externally. I used loopback bindings and SSH port forwarding because the administrative interfaces did not need to exist on the public internet.

What the design genuinely protected

The architecture imposed several meaningful boundaries. MySQL, PHP-FPM and Trilium did not publish public host ports, so public requests had to enter through Caddy. phpMyAdmin and Duplicati were bound to the VPS loopback address and required an authenticated SSH connection with local port forwarding.

MySQL existed only on the internal website network. There was no reason for a public user to connect directly to the database, so the architecture did not provide a path for them to do so. PHP-FPM did not have ordinary unrestricted external connectivity, which restricted some post-exploitation techniques and forced me to think about what network access the application actually required.

Two-factor authentication meant that a stolen WordPress administrator password was not sufficient by itself. The backup destination did not share the same VPS failure point as the source applications, and the backup container received read-only access to the source data.

What the design did not protect

Those boundaries still had limits. PHP-FPM could modify the shared WordPress files and communicate with the database. A malicious PHP file could still be written into the application and accessed through Caddy, and an attacker could continue operating through inbound HTTP without needing a direct outbound reverse shell.

Caddy joined the public network and both internal application networks. Its role required that access, but it also made Caddy a highly trusted component. A compromise of the proxy or the VPS host would have weakened the value of the internal network separation.

Two-factor authentication protected the administrative login process, but it did not remove vulnerabilities from PHP, WordPress core, themes or plugins. The web application firewall could block or detect some malicious requests, but the underlying application still required patching and maintenance.

Keeping the website online became a manual task

Roughly every couple of weeks, I would discover that the site was unavailable or that some part of the WordPress and PHP stack was no longer responding correctly. The containers could still appear to be running while the website itself was down.

The usual way to restore it was to connect to the VPS over SSH and restart the complete Compose project:

sudo docker compose down && sudo docker compose up -d

Bringing the stack down and recreating it normally restored the website, but the failure would return later.

I never identified a single root cause behind every outage. Some appeared related to PHP-FPM, application behaviour, updates or plugin compatibility; others disappeared after a restart without leaving enough evidence to attribute them confidently. Once the same restart became routine, it was simply how I was operating the site.

Most of the services used:

restart: always

That helped when a container process exited completely, but not when the application stopped responding without the container exiting. It also did not resolve broken updates, plugin conflicts or PHP problems.

I could have added health checks, external availability monitoring, automated remediation and more detailed logging. That might have reduced the amount of manual recovery, but it would also have meant building more infrastructure around a personal portfolio.

I had other work, projects and parts of my life that deserved my attention. I did not want a recurring background concern of:

Is my website still running?

A portfolio should make my work easier to find. Instead, I was regularly opening an SSH session and restarting the complete stack just to keep it available.

WordPress felt vulnerable every five minutes

There always seemed to be another reason to review or update the platform: a WordPress core release, a plugin advisory, a theme update, a PHP security issue, an authentication problem, a compatibility warning or another exposed component that needed to be checked.

WordPress itself was only one part of the attack surface. The complete system included WordPress core, PHP, MySQL, themes, plugins, administration interfaces, authentication components, container images, the operating system, Docker and the VPS itself.

Every plugin added another dependency, update process and possible source of failure.

I had started with WordPress because it made publishing easier. Eventually, the effort required to keep the publishing platform secure was greater than the effort required to manage the content.

The security model conflicted with updates

The PHP network restriction created a conflict that became increasingly difficult to ignore. I wanted the runtime isolated because that reduced the options available after code execution, but WordPress expected external connectivity for parts of its normal operation.

Depending on the configuration and plugins in use, that could include downloading core updates, retrieving plugin and theme packages, contacting external APIs, performing background tasks and checking remote services.

WordPress expected the PHP environment to reach external systems, while my security model intentionally removed that access. Allowing it weakened the containment objective; blocking it made legitimate maintenance awkward.

The workarounds were to change the container networking temporarily, apply updates manually, rebuild the application files outside the runtime, introduce carefully scoped exceptions or maintain a separate update process.

Those options may be reasonable for an organisation with a genuine requirement to run WordPress. For my portfolio, I had created a control that restricted an attacker but also made routine patching harder.

Trying to add Suricata

As I became more concerned about malicious traffic reaching WordPress, I looked at running Suricata alongside Caddy. I wanted more visibility into the traffic reaching the site and, if possible, a way to block malicious requests before they reached WordPress.

The placement did not work cleanly. Caddy was already terminating HTTPS, so traffic reaching the VPS before Caddy was encrypted. Placing Suricata after Caddy was not simply a matter of joining the same Docker network. Running it inline on a small, single-interface VPS also required a far more complicated traffic path than I wanted for the site.

The hardware requirements were another problem. This was a small VPS costing around £7 per month, already running Caddy, WordPress, PHP-FPM, MySQL, Trilium and Duplicati. Adding continuous network inspection on top of that made no sense for the available CPU and memory.

I spent time trying to make Caddy and Suricata fit together, but the combination was not practical on that server. I wanted an application-layer control in front of WordPress, while Suricata required a different network position and more resources than the VPS could reasonably provide.

Configuration drift

The Compose files carried the history of the project. Volumes remained declared after services changed to bind mounts, network names still referred to services that had moved into separate projects, and older labels remained after responsibilities changed.

None of that stopped the stack from running, but the final files made far more sense to me than they would have to someone seeing them without that history.

The website looked increasingly ridiculous

The design had become just as frustrating as the reliability and maintenance. WordPress can produce modern websites, but I was constantly working against the platform, its themes and plugin-generated markup.

Changing the site meant accepting the structure provided by the theme, installing more plugins, using a page builder, writing increasingly specific CSS overrides, editing PHP templates or replacing large parts of the presentation layer.

The website needed to present technical projects, long-form research, infrastructure work, forensic analysis, CTF write-ups, archived coursework and supporting evidence. I wanted control over how those types of content related to each other, supported by clear page structures, consistent metadata, reusable presentation patterns and a design appropriate for the work being shown.

WordPress kept pulling the site back towards posts, pages, widgets, themes and plugin-generated components that did not fit the content I wanted to present. The more I tried to modernise it, the more time I spent overriding the platform instead of building the site. It was no longer saving development effort; it had become another layer I was constantly working around. The result looked dated, failed regularly and required far too much maintenance for a website that was supposed to present technical work professionally.

When I stopped adding to it

By the final years of the project, maintaining the portfolio meant maintaining a public reverse proxy, several Docker networks, a separate PHP runtime, MySQL, phpMyAdmin, two-factor authentication, a WAF, backups and SSH access, alongside the abandoned attempt to integrate Suricata.

I was spending more time maintaining that architecture than publishing anything on the site. I was restarting the Compose stack every couple of weeks, working around updates and fighting WordPress whenever I wanted to change the design.

I had other work to focus on and no longer wanted the question of whether my portfolio was online occupying space in my head.

Conclusion

This project began in 2021 as a way for me to learn Docker and understand how containerised services could be used to run a real website. Over the years, it grew into a much larger environment involving Caddy, WordPress, PHP-FPM, MySQL, Trilium, Duplicati, internal networks, SSH port forwarding, two-factor authentication, backups and several attempts to reduce the risks of exposing a PHP application publicly.

Operating it taught me far more than setting up WordPress once and leaving it untouched would have done. The origin rule trusted a spoofable header. Duplicati lost its backup jobs until I persisted /config. Restricting PHP reduced some post-exploitation options but made legitimate updates harder. Suricata did not fit the traffic path or the hardware available, and Docker could show the containers running while the website itself was unavailable.

By the end, the architecture demanded more attention than I could justify for a personal portfolio. I was regularly restarting the stack, working around updates and fighting the limitations of WordPress whenever I wanted to change the site. Retiring the VPS was a natural end to this version of the project.

I am grateful for the years I spent building and operating it, and for everything it taught me along the way. The system served its purpose both as a website and as a practical environment for experimentation. Moving to a different operational model begins the next stage, and I am interested to see where it improves on the old approach, what new limitations it introduces and what I will learn from operating it in practice.