Self-Hosted AI Blog
Network Isolation Is Docker’s Default Behavior
Docker isolates containers into separate networks unless you explicitly bridge them. That’s why cloudflared couldn’t reach your Astro service: they lived in different default networks. The default Docker network (bridge) creates a separate namespace for each container, which means cloudflared running in its own network couldn’t establish a connection to your Astro blog container listening on port 3000.
Here’s the working network config with version-specific details:
services:
cloudflared:
image: cloudflare/cloudflared:v2024.6.1 # Explicit version prevents auto-updates breaking your setup
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TOKEN}
networks:
- default
- astro_webshop_default
restart: unless-stopped # Critical for tunnel stability
volumes:
- /etc/cloudflared:/etc/cloudflared # Persist tunnel config between restarts
astro-blog:
image: ghcr.io/your-org/sovereign-blog:v1.2.3 # Pin your Astro container version
ports:
- "3000:3000"
networks:
- astro_webshop_default
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
astro_webshop_default:
external: true
Run it with:
sudo docker compose -f /config/docker-compose.yml up -d --remove-orphans
Watch out: If you forget the networks block under cloudflared, you’ll see errors like:
ERRO[0001] Failed to dial dial tcp 172.17.0.2:3000: connect: connection refused
The tunnel will die silently and your readers will get Cloudflare’s 404 page instead of your content. Pro tip: Always verify connectivity between containers using:
docker exec -it cloudflared_container_name sh -c "nc -zv astro-blog 3000"
Why This Matters for Sovereign AI
Cloudflare’s tunnel is your privacy layer. If it can’t talk to your blog, your readers see a 404 instead of your content. Bridging the networks keeps traffic local and avoids exposing ports to the internet. Critical limitation: This setup requires Cloudflare Tunnel (not just Cloudflare Proxy) because the latter still exposes your origin server’s IP address. The tunnel creates an encrypted connection directly to your container.
Gotcha: If you’re using Cloudflare Zero Trust policies, ensure your tunnel configuration includes the correct hostname rules. A misconfigured policy like:
Host = blog.yourdomain.com && Path = /admin
will block legitimate traffic if your Astro blog serves admin routes under different paths.
Fixing Orphaned Articles After Slug Changes
Renaming a blog post leaves behind the old file. That breaks the build and creates duplicate slugs. Here’s how to clean up with version-specific commands:
# List all old files with absolute paths
find /home/user/projects/sovereign-blog/src/content/blog -type f -name "*.md" | grep -v "$(git ls-files src/content/blog)"
# Delete the orphaned ones with error handling
find /home/user/projects/sovereign-blog/src/content/blog -type f -name "*.md" | grep -v "$(git ls-files src/content/blog)" | xargs rm -v 2>/dev/null || echo "No orphaned files found"
Watch out: Running this without first committing your changes can cause data loss. Always:
- Commit your current changes first:
git commit -m "Pre-orphan cleanup" - Verify the list of files to delete matches your expectations
- Run the deletion command
Real error scenario: If you have a file like 2023-01-old-title.md that was renamed to 2024-01-new-title.md, the build will fail with:
Error: Duplicate slug detected: "old-title"
This happens because Astro’s content layer generates slugs from filenames, not from frontmatter titles.
Why PNGs Tank Largest Contentful Paint
A 4.2 MB PNG in your hero section pushes LCP past 2.5 seconds. WebP cuts that to 300 KB without visible quality loss. Technical details: PNG uses lossless compression by default, while WebP offers both lossy and lossless modes. For blog hero images, lossy WebP at quality=82 provides the best balance.
Here’s the production-ready converter with error handling:
# scripts/convert_images_to_webp.py
from PIL import Image
import os
import sys
def convert_to_webp(source_dir, quality=82):
converted = 0
failed = 0
for root, _, files in os.walk(source_dir):
for file in files:
if file.lower().endswith(('.png', '.jpg', '.jpeg')):
try:
img_path = os.path.join(root, file)
img = Image.open(img_path)
new_path = os.path.splitext(img_path)[0] + '.webp'
img.save(new_path, 'webp', quality=quality)
os.remove(img_path)
converted += 1
except Exception as e:
print(f"Failed to convert {file}: {str(e)}", file=sys.stderr)
failed += 1
print(f"Converted {converted} images, failed {failed}")
return failed == 0
if __name__ == "__main__":
if not convert_to_webp('/home/user/projects/sovereign-blog/src/images/blog'):
sys.exit(1)
Watch out: Common failure modes:
- Permission errors: Ensure your script has write access to the image directory
- Corrupted images: Some PNGs may fail to open - the script will skip them
- Disk space: WebP conversion creates temporary files - ensure you have 2x free space
Real performance impact: Before conversion:
Largest Contentful Paint: 2.8s (4.2 MB PNG)
After conversion:
Largest Contentful Paint: 1.1s (300 KB WebP)
This represents a 60% improvement in LCP, which directly impacts your SEO rankings.
Handling Lightning Payments Without Third Parties
Lightning integrations must live in your stack, not a SaaS provider. The config lives in config/site-config.json:
{
"lightning": {
"address": "your_node_pubkey@yourdomain.com",
"zap_button": true,
"invoice_expiry": 3600, # 1 hour in seconds
"min_sats": 100, # Minimum payment amount
"max_sats": 1000000 # Maximum payment amount
}
}
Add the button to your footer with error handling:
---
import LightningZap from '../components/LightningZap.astro';
import { getLightningConfig } from '../utils/config';
const config = getLightningConfig();
---
{config.lightning?.zap_button && (
<LightningZap
nodeAddress={config.lightning.address}
minSats={config.lightning.min_sats}
maxSats={config.lightning.max_sats}
/>
)}
Watch out: Common integration pitfalls:
- Node connectivity: If your Lightning node is offline, the zap button will fail silently. Solution: Add a health check endpoint that verifies node connectivity
- Invoice expiry: If a user takes too long to pay, the invoice will expire. Solution: Set
invoice_expiryto match your expected payment time - Amount limits: Without proper validation, users could request absurd amounts. Solution: Always validate amounts on both client and server sides
Real error scenario: If your Lightning node’s public key changes, you’ll see errors like:
Error: Node pubkey mismatch: expected "02abc..." got "03def..."
Solution: Update your site-config.json and redeploy your blog.
What I Actually Use
- Mistral Small 4 v0.1.0: Handles article redacting and frontmatter cleanup without leaking prompts. Used via API endpoint at
https://api.mistral.ai/v1/chat/completions- Pillow 10.1.0: Converts PNGs to WebP in one pass, no extra tools. Installed via
pip install pillow==10.1.0- cloudflared 2024.6.1: Keeps traffic local and avoids port exposure. Configured with
tunnel --config /etc/cloudflared/config.yml run- Astro v4.8.0: Static site generator with built-in image optimization
- Lightning Node: c-lightning v0.12.1 with 100k sats capacity
Self-Hosted AI Blog
Minimal Docker setup for sovereign AI blogging