Self-hosted PHPUnit Code Coverage Reports
Posted on 02 August 2026
5 minute read
I developed and maintain the HIBP-PHP which is currently at time of writing, hosted on GitHub. It contains a complete test suite and for some time, I've pushed the code coverage clover XML report to CodeCov. However, my plan is to move this repo from GitHub for numerous reasons to Codeberg but there's an issue. CodeCov appears to support only a few major git repo vendors unless you want to spend a bunch of time trying to hack something together (or self-host CodeCov (you probably don't!)). So I wanted a different method.
After being lazy and posting a question on Mastodon asking if anyone else had tried using Codeberg/Forgejo to display the coverage report, Andreas Heigl asked a returning question:
Had I generated the HTML report and tried pushing that to a static site provider?
I hadn't (I should have!) and then the penny dropped!
Note: I haven't included the steps here to create the MinIO user account, policy and bucket as that's somewhat out of scope for this particular project and assume this is knowledge already banked if you're running a similar setup.
Coverage report publishing
Phase 1
I self-host a MinIO instance too. I could probably knock up a bash script to run in the CI pipeline to run the mc command and push this to my MinIO server. I decided to have a look to see if anyone else had done this and stumbled across a GitHub action (these are mostly compatible with Codeberg/Forgejo runners) by lovellfelix that handles this all for you. I added this to the workflow config, added the required secrets to Forgejo (this was my test playground) and 💥 it worked. I had the contents of the HTML code coverage report now in a bucket.
Phase 2
Now that the files were sitting pretty in MinIO, I needed to view them. I could access the index.html file directly inside the bucket, which kinda does the job, but I wanted it cleaner. In front of MinIO, I also run nginx as a reverse proxy. I could add configuration there to access the bucket with the coverage report, but I wanted it dynamic, on subdomains, so that hibp-php.example.com would actually access the correct bucket and folder in MinIO so that I could add other projects to it too.
DNS
I bought a new domain to handle this stuff, configured the NS records and then added a wildcard entry *.example.com and pointed that to the server that's hosting my nginx/MinIO services.
MinIO access configuration
To make the reports accessible (assuming these are public report), set the bucket to download permission:
mc anonymous set download minio-instance/the-bucket
nginx configuration to serve the bucket contents as a static site
This config was yanked and tweaked from my MinIO config, so could likely do with some extra refining, but this works for now (the include files here aren't important for the configuration):
server {
server_tokens off;
listen 443 ssl;
http2 on;
server_name ~^(?<subdomain>[^\.]+)\.codecoverage\.site$;
include /etc/nginx/global/realip.conf;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
include /etc/nginx/ssl/ssl.conf;
include /etc/nginx/ssl/headers.conf;
proxy_buffering off;
location ~ /.well-known/ {
allow all;
root /var/www/.letsencrypt/codecoverage.site;
}
root /var/www/personal/codecoverage;
index index.html;
location / {
rewrite ^/$ /index.html break;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $proxy_connection;
proxy_set_header X-Forwarded-Host $the_host;
proxy_set_header X-Forwarded-Proto $the_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 15m; # Default value is 60s which is not sumfficient for MinIO.
proxy_send_timeout 15m; # Default value is 60s which is not sufficient for MinIO.
proxy_request_buffering off; # Disable any internal request bufferring.
proxy_pass http://127.0.0.1:9000/the-bucket/$subdomain$uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_headers_hash_max_size 512;
proxy_headers_hash_bucket_size 128;
proxy_intercept_errors on;
error_page 403 404 = @error;
}
location @error {
proxy_pass http://127.0.0.1:9000/code-coverage/$subdomain/error.html;
}
# Global config options
include /etc/nginx/global/global_no_php.conf;
access_log /var/log/nginx/codecoverage_access.log;
error_log /var/log/nginx/codecoverage_error.log;
}
The "secret" here is the rewrite for index.html and to intercept the error handling. I had a bit of an issue initially with the index page loading, but none of the CSS assets, but this was all resolved with a few trial and errors. The error handling was the one that caused the biggest headache as without intercepting these, the assets were served for some reason as text/html which... they're not.
Now, the CI pipeline pushes the report to the-bucket/name-of-project and you can then access that as https://name-of-project.example.com and it'll serve the correct report.
My next part for this is to create a static landing page for the root domain (and www subdomain) as currently, they both serve an Access Denied error page from MinIO due to there being no permissions to access things outside of this coverage configuration.
For a real world example, the HIBP-PHP PHP package for accessing the HaveIBeenPwned API now has the coverage report hosted at https://hibp-php.codecoverage.site.
Thanks to Andreas for nudging me in the direction of static site hosting 👍🏻