15 Dec 2025
How I built a production-ready document management system using native XML databases, Docker Compose, and modern DevOps practices
As a DevOps engineer, I wanted to build a project that showcases practical skills beyond the typical CRUD app. I needed something that demonstrates:
The result? A document management system using eXist-db (a native XML database), PHP, Nginx, and Docker Compose - all exposed securely via Cloudflare Tunnel.
Live demo: existdbapp.lioprojects.xyz
Most portfolio projects use MySQL or PostgreSQL. I wanted to work with something different that would stand out in interviews. eXist-db is:
It's also rare enough that demonstrating competency with it shows adaptability and willingness to learn unconventional technologies.
The system consists of three main components:

Why this stack?
Here's what happens when a user uploads a document:
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="text" name="title" placeholder="Document Title" required>
<input type="text" name="author" placeholder="Author" required>
<input type="file" name="file" required>
<button type="submit">Upload</button>
</form>
// Extract form data
$title = htmlspecialchars($_POST['title'], ENT_QUOTES, 'UTF-8');
$author = htmlspecialchars($_POST['author'], ENT_QUOTES, 'UTF-8');
$fileContent = file_get_contents($_FILES['file']['tmp_name']);
// Encode file in Base64 for XML storage
$base64Content = base64_encode($fileContent);
// Generate unique ID
$docId = uniqid('doc_', true);
$xml = new DOMDocument('1.0', 'UTF-8');
$document = $xml->createElement('document');
$document->appendChild($xml->createElement('id', $docId));
$document->appendChild($xml->createElement('title', $title));
$document->appendChild($xml->createElement('author', $author));
$document->appendChild($xml->createElement('created', date('Y-m-d H:i:s')));
$document->appendChild($xml->createElement('filename', $filename));
// Embed file content
$contentNode = $xml->createElement('content');
$contentNode->setAttribute('encoding', 'base64');
$contentNode->appendChild($xml->createTextNode($base64Content));
$document->appendChild($contentNode);
Why XML instead of JSON?
$existUrl = "http://exist:8080/exist/rest/db/docs/{$docId}.xml";
$ch = curl_init($existUrl);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/xml']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $xml->saveXML());
curl_setopt($ch, CURLOPT_USERPWD, "admin:");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
Key DevOps concepts here:
The magic happens in "docker-compose.yml":
version: "3.8"
services:
php:
image: php:8.2-fpm-alpine
volumes:
- ./php-app:/var/www/html
command: sh -c "apk add --no-cache curl && php-fpm"
networks:
- app-network
nginx:
image: nginx:alpine
ports:
- "8081:80"
volumes:
- ./php-app:/var/www/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
networks:
- app-network
exist:
image: existdb/existdb:5.3.0
ports:
- "8080:8080"
volumes:
- exist-data:/exist-data
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
exist-data:
Why these choices?
server {
listen 80;
server_name _; # Accepts any hostname (crucial for Cloudflare Tunnel)
root /var/www/html;
index index.php index.html;
location ~ \.php$ {
fastcgi_pass php-fpm:9000; # Container service name
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
Critical detail: "server_name _" accepts any "Host" header. This is essential for Cloudflare Tunnel, which may send different headers than your domain name.
Building this taught me several valuable lessons:
Initial mistake:
$existUrl = "http://localhost:8080/..."; // ❌ Doesn't work!
Correct:
$existUrl = "http://exist:8080/..."; // ✅ Uses Docker DNS
Why? Each container has its own "localhost". Services communicate via the Docker network using service names.
I originally mounted directories like this:
volumes:
- /home/user/exist-data:/exist/data # ❌ Overwrites internal files
This deleted eXist-db's default applications! Solution:
volumes:
- exist-data:/exist-data # ✅ Named volume, Docker manages it
eXist-db 6.x has a bug with REST PUT requests from Alpine curl. After hours of debugging, I discovered:
Lesson: Always check GitHub issues when facing mysterious errors with specific versions.
PHP couldn't write to "uploads/" directory. The fix:
mkdir -p php-app/uploads
chmod 755 php-app/uploads
When you mount a directory from host to container, host permissions apply.
The final system allows users to:
Performance metrics:
In Part 2, I'll cover:
In Part 3, I'll discuss:
The complete code is available on my GitHub: [https://github.com/LionelBenvino/exist-document-manager]
To run locally:
git clone <repo>
cd exist-project
docker-compose up -d
Visit "http://localhost:8081" to see it in action!
This is part of my DevOps learning journey. Follow along as I build production-ready systems and document everything I learn.