Chapter 7 — Scenario: Apache Web Server
Time to put Docker's fundamentals to practical use. In this chapter you'll
build a working Apache web server inside a Docker container that serves your
actual website files — and then make it publicly accessible on the internet
using Cloudflare Tunnel, with no router configuration, no port forwarding,
and no static IP required.
This gives you a useful fallback for osztromok.com: if your primary server
ever goes down for maintenance or has a problem, you can spin up this container
on any machine — your Raspberry Pi, a laptop, a VPS — and the site stays up
while you fix the real issue. The whole thing takes about ten minutes once it's
configured.
What we're building
→
→
🔒
CF TunnelEncrypted outbound
→
→
→
📁
Your site filesbind mount
We'll run two containers on a shared Docker network: one running Apache
(httpd) serving your site files, and one running
cloudflared creating a secure outbound tunnel to Cloudflare's
network. Visitors reach your site through Cloudflare — your machine never needs
an open inbound port.
The httpd image is the official Apache HTTP Server image on
Docker Hub, maintained by the Apache project. It's production-ready, security-
patched regularly, and remarkably easy to use — just mount your files at the
right path and it serves them.
Terminal — pull and inspect
# Pull the latest stable Apache image
$ docker pull httpd:2.4
2.4: Pulling from library/httpd
Digest: sha256:a5f58c...
Status: Downloaded newer image for httpd:2.4
# Where does httpd serve files from inside the container?
$ docker inspect httpd:2.4 --format '{{json .Config.Env}}'
["PATH=/usr/local/apache2/bin:...", "HTTPD_VERSION=2.4.62"]
# The document root is /usr/local/apache2/htdocs
# Quick test — serve the default Apache page
$ docker run -d -p 8080:80 --name apache-test httpd:2.4
$ curl http://localhost:8080
<html><body><h1>It works!</h1></body></html>
$ docker rm -f apache-test
Why tag 2.4 not latest? The latest tag follows
whatever the most recent release is. For a web server used as a fallback,
you want predictability — pin to 2.4 and you'll always get the
current 2.4.x patch release, never accidentally jump to a major version that
might change behaviour.
Apache inside the container looks for files in
/usr/local/apache2/htdocs. We'll use a bind mount
to point that path at your actual website folder on the host machine. Any
changes you make to the files on disk are immediately visible to Apache —
no restart, no rebuild.
# Replace the path with your actual website folder
# Windows PowerShell — use ${PWD} or an absolute path
docker run -d \
--name osztromok-fallback \
-p 8080:80 \
-v C:\Users\emuba\OneDrive\My Learning\WebSites\website:/usr/local/apache2/htdocs:ro \
httpd:2.4
# :ro makes it read-only — Apache should never write to your source files
Terminal — verify the site loads
$ docker run -d --name osztromok-fallback -p 8080:80 -v "C:\Users\emuba\OneDrive\My Learning\WebSites\website":/usr/local/apache2/htdocs:ro httpd:2.4
9f4c2a1b8e3d...
# Check it started cleanly
$ docker logs osztromok-fallback
AH00558: httpd: Could not reliably determine the server's fully qualified domain name
[Mon Jun 16 10:23:01.000000 2025] [mpm_event:notice] [pid 1:tid 1] AH00489: Apache/2.4.62 configured
[Mon Jun 16 10:23:01.000001 2025] [mpm_event:notice] [pid 1:tid 1] AH00094: Command line: 'httpd -D FOREGROUND'
# The "fully qualified domain name" warning is harmless — ignore it
# Open http://localhost:8080 in your browser to see your site
Windows path with spaces: When your path contains spaces
(as yours does — "My Learning"), always wrap the entire -v
argument in double quotes in PowerShell:
-v "C:\Users\emuba\OneDrive\My Learning\WebSites\website":/usr/local/apache2/htdocs:ro
The default httpd.conf works for basic file serving, but you
might want to tweak it — for example to enable .htaccess files,
set up a virtual host for your domain, or enable mod_rewrite.
There are two ways to do this.
Option A — Mount a custom httpd.conf
Extract the default config, edit it, then mount it back in:
Terminal — extract default config
# Copy the default config out of the image so you have something to edit
$ docker run --rm httpd:2.4 cat /usr/local/apache2/conf/httpd.conf > my-httpd.conf
# Edit my-httpd.conf, then mount it back in
$ docker run -d \
--name osztromok-fallback \
-p 8080:80 \
-v "C:\Users\emuba\OneDrive\My Learning\WebSites\website":/usr/local/apache2/htdocs:ro \
-v "$(pwd)/my-httpd.conf":/usr/local/apache2/conf/httpd.conf:ro \
httpd:2.4
Option B — Build a custom image (recommended for repeatability)
If your config changes are permanent, bake them into a custom image.
This is the Dockerfile approach from Chapter 6 applied to Apache:
# Dockerfile for a pre-configured Apache fallback server
FROM httpd:2.4
# Copy a customised httpd.conf (you extracted and edited this)
COPY my-httpd.conf /usr/local/apache2/conf/httpd.conf
# Copy your site files directly into the image
# (alternative to bind mount — good for a truly portable image)
COPY website/ /usr/local/apache2/htdocs/
Key httpd.conf tweaks for osztromok.com
# Enable mod_rewrite (needed for clean URLs / .htaccess rewrites)
# Uncomment this line in httpd.conf:
LoadModule rewrite_module modules/mod_rewrite.so
# Allow .htaccess to override settings in your document root
# Find the <Directory "/usr/local/apache2/htdocs"> block and change:
AllowOverride None # default — change to:
AllowOverride All
# Set your server name to suppress the FQDN warning
ServerName osztromok.com
# Optional: add a virtual host block at the bottom of httpd.conf
<VirtualHost *:80>
ServerName osztromok.com
ServerAlias www.osztromok.com
DocumentRoot /usr/local/apache2/htdocs
DirectoryIndex index.html index.php
</VirtualHost>
Test your config before restarting: Run
docker exec osztromok-fallback httpd -t to validate the configuration
syntax. Apache prints "Syntax OK" if everything is fine, or a helpful error
message if something is wrong.
Your container currently answers on localhost:8080 — only
accessible from the machine it's running on. To make it reachable at
osztromok.com from the internet, you'd normally need to open
inbound ports on your router and have a static IP. Cloudflare Tunnel eliminates
both requirements entirely.
The cloudflared process runs as its own container, opens an
outbound encrypted connection to Cloudflare's edge, and tells Cloudflare to
route traffic for your domain down that tunnel to your Apache container — all
without a single inbound port.
Visitor
https://osztromok.com
↓ HTTPS request hits Cloudflare's edge
Cloudflare Network
Proxies + DDoS protection + CDN
↓ Cloudflare routes down the tunnel (outbound connection your machine initiated)
cloudflared container
Maintains encrypted tunnel · Docker custom network
↓ Forwards to httpd container by name (Docker DNS)
httpd container · port 80
Serves files from bind-mounted /htdocs
↓ Reads from
Host filesystem
C:\…\website\ (bind mount :ro)
Prerequisites
- Your domain (osztromok.com) must be on Cloudflare's nameservers (it already is if you're using Cloudflare Tunnel for your main server)
- You need a Cloudflare account with access to the Zero Trust dashboard
- The tunnel token you'll generate in the next step
Creating the tunnel token
One-time setup in the Cloudflare dashboard:
1. Go to Cloudflare Zero Trust → Networks → Tunnels
2. Click Add a tunnel → choose Cloudflared
3. Give it a name (e.g. osztromok-docker-fallback)
4. Copy the token — a long string starting with eyJ...
5. Under Public Hostnames, add: Subdomain @, Domain osztromok.com, Service http://osztromok-apache:80
The service URL uses the Docker container name — this is Docker's internal DNS resolving the httpd container by name on the shared network.
From Chapter 5 you know that containers on the same custom network can
reach each other by name. We'll create a network, start Apache on it (named
osztromok-apache so Cloudflare can route to it), then start
cloudflared on the same network.
# 1. Create a dedicated network for these two containers
$ docker network create osztromok-net
# 2. Start Apache — note the name matches what Cloudflare tunnel points to
$ docker run -d \
--name osztromok-apache \
--network osztromok-net \
-v "C:\Users\emuba\OneDrive\My Learning\WebSites\website":/usr/local/apache2/htdocs:ro \
httpd:2.4
a3f9c2...
# 3. Start cloudflared — paste your tunnel token in place of TOKEN_HERE
$ docker run -d \
--name osztromok-tunnel \
--network osztromok-net \
cloudflare/cloudflared:latest tunnel \
--no-autoupdate run \
--token TOKEN_HERE
b7e1d4...
# 4. Check both are running
$ docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
NAMES STATUS IMAGE
osztromok-tunnel Up 8 seconds cloudflare/cloudflared:latest
osztromok-apache Up 12 seconds httpd:2.4
# 5. Watch the tunnel connect
$ docker logs osztromok-tunnel
INF Starting metrics server on 127.0.0.1:2000/metrics
INF Registered tunnel connection connIndex=0 ip=198.41.200.33 location=LHR
INF Registered tunnel connection connIndex=1 ip=198.41.200.34 location=LHR
# Once you see "Registered tunnel connection" it's live — visit osztromok.com
Don't expose port 8080 when using the tunnel. The tunnel
gives Cloudflare access to your container by name on the internal network.
You don't need (and shouldn't have) -p 8080:80 on the Apache
container in production — that would open it to everyone on your local network.
Only add -p when you want localhost access for testing.
When your primary server goes down, you want to activate the fallback in
seconds, not spend time reconstructing commands. Save this as a PowerShell
script and keep it somewhere you can find it quickly.
# start-fallback.ps1
# ──────────────────────────────────────────────────────────────────
# osztromok.com Docker fallback web server
# Run this if the primary server is down.
# ──────────────────────────────────────────────────────────────────
$SITE_PATH = "C:\Users\emuba\OneDrive\My Learning\WebSites\website"
$CF_TOKEN = "eyJ..." # paste your Cloudflare tunnel token here
$NETWORK = "osztromok-net"
$APACHE_IMG = "httpd:2.4"
$CF_IMG = "cloudflare/cloudflared:latest"
# Ensure the images are up to date
docker pull $APACHE_IMG
docker pull $CF_IMG
# Remove any leftover containers from a previous run
docker rm -f osztromok-apache osztromok-tunnel 2>&$null
# Create network (safe to run even if it already exists)
docker network create $NETWORK 2>&$null
# Start Apache
docker run -d `
--name osztromok-apache `
--network $NETWORK `
--restart unless-stopped `
-v "${SITE_PATH}":/usr/local/apache2/htdocs:ro `
$APACHE_IMG
# Start Cloudflare Tunnel
docker run -d `
--name osztromok-tunnel `
--network $NETWORK `
--restart unless-stopped `
$CF_IMG tunnel --no-autoupdate run --token $CF_TOKEN
Write-Host "Fallback started. Waiting for tunnel to connect..."
Start-Sleep 5
docker logs --tail 5 osztromok-tunnel
--restart unless-stopped means both containers will come back
up automatically if your machine reboots while the fallback is active. To
stop the fallback intentionally, run
docker stop osztromok-apache osztromok-tunnel — after an explicit
stop, the containers won't auto-restart.
Stopping the fallback when your main server is back
# stop-fallback.ps1
docker stop osztromok-apache osztromok-tunnel
docker rm osztromok-apache osztromok-tunnel
docker network rm osztromok-net
Write-Host "Fallback stopped. Remember to update DNS back to your primary server."
Symptom
Site shows "403 Forbidden"
Fix
Apache can't read the files. Check that index.html exists in your website root. On Windows you may need to share the drive in Docker Desktop Settings → Resources → File Sharing.
Symptom
Tunnel logs show "failed to connect" or exits immediately
Fix
Wrong or expired token. Re-generate the token in the Cloudflare Zero Trust dashboard, delete the container, and re-run with the new token.
Symptom
tunnel logs show "connection refused" reaching osztromok-apache
Fix
Container names must match exactly. The tunnel config says http://osztromok-apache:80 — verify the Apache container has exactly that name: docker ps --format '{{.Names}}'
Symptom
CSS/images load on homepage but 404 on other pages
Fix
mod_rewrite not enabled. Edit httpd.conf to uncomment the rewrite_module line and set AllowOverride All, then restart: docker restart osztromok-apache
Symptom
Docker Desktop says drive sharing is blocked
Fix
In Docker Desktop → Settings → Resources → File Sharing, add C:\Users\emuba\OneDrive. OneDrive paths sometimes need explicit permission.
Terminal — useful diagnostic commands
# Check Apache is serving files (from inside the container)
$ docker exec osztromok-apache ls /usr/local/apache2/htdocs
# Validate Apache config syntax
$ docker exec osztromok-apache httpd -t
Syntax OK
# Test Apache is reachable from the cloudflared container
$ docker exec osztromok-tunnel wget -qO- http://osztromok-apache:80
# Follow live access logs
$ docker logs -f osztromok-apache
192.168.1.1 - - [16/Jun/2025:10:45:02 +0000] "GET / HTTP/1.1" 200 4521
192.168.1.1 - - [16/Jun/2025:10:45:02 +0000] "GET /css/style.css HTTP/1.1" 200 8103
Fallback activation checklist
- Docker Desktop is running
- Run
start-fallback.ps1
- Wait for "Registered tunnel connection" in cloudflared logs
- Visit
https://osztromok.com and confirm the site loads
- Check Cloudflare Zero Trust dashboard → Tunnels → your tunnel is "Healthy"
- When primary server is back: run
stop-fallback.ps1
- Verify primary server is responding before stopping fallback
Exercises
- Local test run. Start just the Apache container (no tunnel yet) with
-p 8080:80 and bind-mount your website folder. Visit http://localhost:8080 and navigate around the site. Make a visible edit to index.html (e.g. change a heading), save the file, and refresh the browser — the change should appear instantly without restarting the container, because it reads directly from the bind mount.
- Inspect the access log. While the container is running and you browse the site, run
docker logs -f osztromok-apache. Notice how each page visit, stylesheet, image and script generates a separate log line. Try visiting a page that doesn't exist and observe the 404 log entry.
- Validate your httpd.conf. Extract the default config with
docker run --rm httpd:2.4 cat /usr/local/apache2/conf/httpd.conf > my-httpd.conf. Open the file and find the lines that need uncommenting to enable mod_rewrite and set AllowOverride All. Make those changes, mount the config, restart, and run docker exec osztromok-apache httpd -t to confirm "Syntax OK".
- Set up a tunnel (if you have Cloudflare access). Follow Step 4 to create a tunnel in Cloudflare Zero Trust. Run both containers using the commands in Step 5, wait for the tunnel to show "Registered", and visit
https://osztromok.com — you should see your site served from the Docker container. Check the Cloudflare dashboard to confirm the tunnel is "Healthy".
- Create the startup script. Copy the
start-fallback.ps1 from Step 6 into a real file on your machine, fill in your Cloudflare token and site path, and do a full test run: stop any running fallback containers, run the script, confirm the site loads, then run stop-fallback.ps1. Store both scripts somewhere you'll find them under pressure — desktop, OneDrive, or a dedicated ops folder.
Next: Chapter 8 — Scenario: Python Development Environment
Now that you've seen Docker used as an operations tool, Chapter 8 switches to
the developer angle: building a Python development container for FastAPI work.
You'll keep dependencies isolated from your Windows system, use VS Code's
Dev Containers extension to edit code inside the container, and never touch
your host Python installation again.