16 Dec 2025
"From local Docker Compose to production deployment with Cloudflare Tunnel - secure public access without exposing ports"
In Part 1, I built a document management system with eXist-db and Docker. Now it's time to deploy it to production and make it accessible to the world - securely, without opening firewall ports.
Live demo: existdbapp.lioprojects.xyz
As a DevOps engineer, I wanted to demonstrate:
Internet
↓
Cloudflare Global Network
↓ (Encrypted Tunnel)
Cloudflared (systemd service)
↓
Nginx (:8081) → PHP-FPM → eXist-db
↑
Docker Compose (internal network)
Why this architecture?
I used a Ubuntu 22.04 VPS (any cloud provider works):
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Install Docker Compose
sudo apt install docker-compose-plugin
# Verify installation
docker --version
docker compose version
# Create project directory
mkdir -p ~/exist-project
cd ~/exist-project
# Clone repository (or create files manually)
git clone <your-repo> .
# Create necessary directories
mkdir -p php-app/uploads nginx
chmod 755 php-app/uploads
# Start services
docker compose up -d
# Check status
docker ps
docker logs nginx -f
docker logs exist -f
Verify local access:
curl -I http://localhost:8081
# Should return: HTTP/1.1 200 OK
At this point, the app works locally but isn't accessible from the internet.
Cloudflare Tunnel (formerly Argo Tunnel) creates an outbound-only connection from your server to Cloudflare's edge network. No inbound firewall rules needed.
# Download latest release
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
-o /usr/local/bin/cloudflared
# Make executable
chmod +x /usr/local/bin/cloudflared
# Verify installation
cloudflared version
cloudflared tunnel login
This opens a browser window. Log in to your Cloudflare account and select your domain. A certificate file is saved to "~/.cloudflared/cert.pem".
cloudflared tunnel create lio-existdb
Output:
Created tunnel lio-existdb with id 30da90b7-0238-4155-9473-67a7291a968c
Important: Save this UUID! You'll need it for configuration.
A credentials file is created at:
~/.cloudflared/30da90b7-0238-4155-9473-67a7291a968c.json
Create the configuration file:
sudo mkdir -p /etc/cloudflared
sudo nano /etc/cloudflared/config.yml
config.yml:
tunnel: lio-existdb
credentials-file: /root/.cloudflared/30da90b7-0238-4155-9473-67a7291a968c.json
ingress:
- hostname: existdbapp.lioprojects.xyz
service: http://localhost:8081
- service: http_status:404
What this does:
Copy credentials file:
sudo cp ~/.cloudflared/*.json /root/.cloudflared/
In Cloudflare Dashboard:
Go to DNS settings for "lioprojects.xyz"
Add a CNAME record:
Name: existdbapp
Target: <TUNNEL-ID>.cfargotunnel.com
Proxy: ON (orange cloud)
The tunnel ID is the UUID from step 3
DNS propagation usually takes 1-5 minutes.
Connect your tunnel to the DNS record:
cloudflared tunnel route dns lio-existdb existdbapp.lioprojects.xyz
Verify:
cloudflared tunnel info lio-existdb
Why systemd?
# Install as service
sudo cloudflared service install
# Enable auto-start
sudo systemctl enable cloudflared
# Start the service
sudo systemctl start cloudflared
# Check status
sudo systemctl status cloudflared
View logs:
sudo journalctl -u cloudflared -f
You should see:
Connection established
Registered tunnel connection
Problem: Nginx rejects requests because the "Host" header doesn't match "server_name".
Original config (broken):
server {
server_name existdbapp.lioprojects.xyz; # ❌ Too restrictive
...
}
Fixed config:
server {
server_name _; # ✅ Accepts any Host header
...
}
Why? Cloudflare Tunnel may send requests with different Host headers depending on routing. Using _ makes nginx accept any hostname.
Apply changes:
docker compose restart nginx
# From anywhere in the world
curl -I https://existdbapp.lioprojects.xyz
# Should return
HTTP/2 200
server: cloudflare
Test upload:
✅ No exposed ports - Only port 22 (SSH) is open
✅ DDoS protection - Cloudflare absorbs attacks
✅ Automatic HTTPS - Cloudflare manages certificates
✅ Rate limiting - Built into Cloudflare (configurable)
⚠️ Authentication - No login system yet
⚠️ eXist-db admin password - Still default (change it!)
⚠️ File upload limits - Should implement size restrictions
⚠️ Input validation - Basic sanitization only
Production hardening checklist:
# 1. Change eXist-db password
docker exec -it exist /exist/bin/client.sh -u admin -P
# 2. Add firewall rules (only if not using tunnel)
sudo ufw allow 22/tcp
sudo ufw enable
# 3. Set up fail2ban for SSH
sudo apt install fail2ban
Add to "docker-compose.yml":
services:
nginx:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 30s
timeout: 10s
retries: 3
exist:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/exist"]
interval: 30s
timeout: 10s
retries: 3
# Docker containers
docker ps
docker stats
# Cloudflare tunnel
sudo systemctl status cloudflared
sudo journalctl -u cloudflared --since "1 hour ago"
# Disk usage
df -h
du -sh ~/exist-project
# Backup eXist-db data
docker exec exist java -jar /exist/start.jar backup \
-u admin -p "" -b /tmp/backup -c /db
# Copy backup out
docker cp exist:/tmp/backup ./backups/$(date +%Y%m%d)
# Backup Docker volumes
docker run --rm -v exist-project_exist-data:/data \
-v $(pwd)/backups:/backup alpine \
tar czf /backup/exist-data-$(date +%Y%m%d).tar.gz /data
Automate with cron:
# Edit crontab
crontab -e
# Add daily backup at 2 AM
0 2 * * * /path/to/backup-script.sh
After deployment, I measured:
| Metric | Value |
|---|---|
| Initial load | 380ms (Cloudflare CDN) |
| Document upload (1MB) | 1.2s |
| List query (50 docs) | 85ms |
| Container memory | ~450MB total |
| CPU idle | <5% |
Cloudflare Analytics shows:
Symptoms: Cloudflare shows 502 error
Causes:
Fix:
docker compose ps # Check containers
docker logs nginx # Check nginx logs
curl http://localhost:8081 # Test locally
Symptoms: "systemctl status cloudflared" shows errors
Causes:
Fix:
# Validate config
cloudflared tunnel ingress validate
# Test manually
cloudflared tunnel run lio-existdb
# Check DNS
nslookup existdbapp.lioprojects.xyz
Symptoms: 404 errors, but containers running
Cause: "server_name" mismatch
Fix: Change to "server_name _;" as shown in Step 8
| Service | Monthly Cost |
|---|---|
| VPS (2GB RAM, 1 CPU) | $5-10 |
| Domain (already owned) | $0 |
| Cloudflare Tunnel | FREE |
| Total | ~$7/month |
Free tier includes:
In Part 3, I'll cover:
This deployment demonstrates:
✅ Infrastructure as Code - Reproducible with docker-compose.yml
✅ Zero-Trust Architecture - No exposed attack surface
✅ Service Management - systemd for production services
✅ Reverse Proxy Configuration - Nginx best practices
✅ Container Orchestration - Multi-service Docker Compose
✅ DNS & Networking - Understanding of how traffic flows
✅ Troubleshooting - Systematic debugging approach
The complete code is on GitHub: link
Deploy in 5 minutes:
git clone <repo>
cd exist-project
docker compose up -d
# Follow Cloudflare steps above
Building in public and documenting my DevOps journey. Follow for more production-ready projects.