Subdomains End-to-End

Chapter 6 — Subdomains End-to-End

Previous chapters covered DNS, Cloudflare, tunnels, and virtual hosts as separate subjects. This chapter pulls them together: a subdomain only works when both the DNS layer and the server layer are configured correctly. Miss either one and the subdomain either doesn't resolve or resolves to the wrong place. This chapter is a practical integration guide — complete walkthroughs, common patterns, and the exact sequence to follow every time you add a new subdomain.

What this chapter covers: The two-part requirement and why both parts must align. Complete end-to-end walkthrough for a static subdomain. Redirect www to the root domain (two methods). Wildcard subdomains in DNS and Apache. Reverse proxying to a backend application running on a different port. Subdomain deployment checklist. Troubleshooting: subdomain not resolving, SSL errors, serving wrong content.

The Two-Part Requirement

A subdomain like blog.osztromok.com requires two completely separate systems to agree with each other. If either is missing, the subdomain fails — and the error messages are different depending on which part is absent.

1
DNS — tells the internet where to send traffic
A DNS record in Cloudflare pointing blog.osztromok.com to Cloudflare's edge (via the tunnel CNAME or a direct A record).

What goes wrong without it: Browser shows "This site can't be reached / DNS_PROBE_FINISHED_NXDOMAIN" — the domain doesn't resolve at all. dig blog.osztromok.com returns NXDOMAIN (no record found).
2
Server — tells Apache what to serve when traffic arrives
An Apache virtual host with ServerName blog.osztromok.com pointing to the correct document root (or ProxyPass to a backend).

What goes wrong without it: DNS resolves correctly, browser reaches Apache, but Apache serves the default vhost (or returns 403/404). The right domain, the wrong content.
Complete path — everything must be in place: ┌──────────────────────────────────────────────────────────────────┐ │ Part 1: DNS layer │ │ blog.osztromok.com ─CNAME→ tunnel.cfargotunnel.com │ │ (Cloudflare DNS panel, or auto-created by cloudflared) │ └──────────────────────┬───────────────────────────────────────────┘ │ resolves → Cloudflare edge ▼ ┌──────────────────────────────────────────────────────────────────┐ │ Cloudflare → tunnel → cloudflared → localhost:80 │ │ Host header: blog.osztromok.com (preserved through tunnel) │ └──────────────────────┬───────────────────────────────────────────┘ │ arrives at Apache ▼ ┌──────────────────────────────────────────────────────────────────┐ │ Part 2: Server layer │ │ VirtualHost *:80 { ServerName blog.osztromok.com } │ │ DocumentRoot /var/www/blog.osztromok.com/public_html │ │ (/etc/apache2/sites-enabled/blog.osztromok.com.conf) │ └──────────────────────────────────────────────────────────────────┘

Common Subdomain Patterns

www.osztromok.com
www alias
Traditional alias for the root domain. Usually configured to redirect to the apex — most sites pick one canonical form and redirect the other.
blog.osztromok.com
content site
Separate document root, can run a completely different CMS (WordPress, Ghost) or just static HTML. Independent Apache vhost.
api.osztromok.com
backend app
ProxyPass to a backend (Flask, FastAPI, Node) running on a different port. Apache acts as a reverse proxy — the app never knows about subdomain routing.
dev.osztromok.com
staging / dev
Separate document root with a work-in-progress version of the site. Optional: restrict access by IP with a Require ip directive.
*.osztromok.com
wildcard
One DNS record matches all subdomains. Combined with a wildcard Apache ServerAlias, useful for multi-tenant apps or catching unknown subdomains.
status.osztromok.com
monitoring
Uptime / status page (Uptime Kuma, Cachet). Runs on its own port, served via ProxyPass. Can be hosted separately so it stays up when the main site is down.

Scenario 1 — Static Subdomain End-to-End

Full Walkthrough · blog.osztromok.com
Create a working blog subdomain serving static files — from empty directory to publicly accessible URL, with Cloudflare Tunnel.
1
Create the document root and a test page.
$ sudo mkdir -p /var/www/blog.osztromok.com/public_html $ sudo chown -R philip:www-data /var/www/blog.osztromok.com $ sudo find /var/www/blog.osztromok.com -type d -exec chmod 755 {} \; $ cat <<'EOF' | sudo tee /var/www/blog.osztromok.com/public_html/index.html <!DOCTYPE html> <html><body><h1>blog.osztromok.com</h1><p>Subdomain working.</p></body></html> EOF
2
Write the Apache vhost config.
$ sudo nano /etc/apache2/sites-available/blog.osztromok.com.conf
<VirtualHost *:80> ServerName blog.osztromok.com DocumentRoot /var/www/blog.osztromok.com/public_html <Directory /var/www/blog.osztromok.com/public_html> Options FollowSymLinks AllowOverride All Require all granted </Directory> ErrorLog ${APACHE_LOG_DIR}/blog_error.log CustomLog ${APACHE_LOG_DIR}/blog_access.log combined </VirtualHost>
3
Enable, test config, reload Apache.
$ sudo a2ensite blog.osztromok.com.conf $ sudo apache2ctl configtest Syntax OK $ sudo systemctl reload apache2
4
Test locally — verify Apache routing before touching DNS. This is the most valuable debugging step: it confirms Apache is working independently of DNS and the tunnel.
$ curl -H "Host: blog.osztromok.com" http://localhost <!DOCTYPE html> <html><body><h1>blog.osztromok.com</h1><p>Subdomain working.</p></body></html> # Correct content = Apache vhost is working. Proceed to DNS. # Also confirm the main site still works (no regression) $ curl -H "Host: osztromok.com" http://localhost | head -3
5
Add the DNS record in Cloudflare. Go to your Cloudflare DNS dashboard for osztromok.com and add:

Type: CNAME  |  Name: blog  |  Target: osztromok.com  |  Proxy: orange cloud (proxied)

If using Cloudflare Tunnel: instead of a DNS record, go to Zero Trust → Networks → Tunnels → your tunnel → Public Hostnames → Add a public hostname:
Subdomain: blog · Domain: osztromok.com · Service Type: HTTP · URL: localhost:80
The tunnel creates the CNAME automatically and routes traffic through. No separate DNS step needed.
6
Verify DNS propagation and test from outside.
$ dig blog.osztromok.com +short 104.21.x.x ← Cloudflare IP = DNS record is live and proxied # From another machine / your phone on mobile data: $ curl https://blog.osztromok.com <h1>blog.osztromok.com</h1> ← subdomain working end-to-end
If DNS isn't resolving yet, wait 1–2 minutes (Cloudflare propagates quickly) and try again.
Always test locally with curl before touching DNS — it separates Apache problems from DNS problems. If curl localhost works but the public URL doesn't, the problem is in DNS or the tunnel, not Apache.

Scenario 2 — Redirecting www to the Root Domain

Most sites pick one canonical form — either www.osztromok.com or osztromok.com — and redirect the other. This matters for SEO (duplicate content) and for user expectations. The standard modern approach is to use the apex (osztromok.com) as canonical and redirect www to it.

Method A — Apache redirect vhost (server-side)

# /etc/apache2/sites-available/www-redirect.conf # A dedicated vhost whose only job is to redirect www → apex <VirtualHost *:80> ServerName www.osztromok.com # Permanent redirect (301) — browsers and search engines remember this Redirect permanent / https://osztromok.com/ </VirtualHost>
$ sudo a2ensite www-redirect.conf $ sudo apache2ctl configtest && sudo systemctl reload apache2 # Test the redirect (follow the redirect with -L, show headers with -I) $ curl -I -H "Host: www.osztromok.com" http://localhost HTTP/1.1 301 Moved Permanently Location: https://osztromok.com/ ← correct destination
Don't forget the DNS record for www. You need a CNAME for www pointing to osztromok.com in Cloudflare, otherwise the redirect vhost is never reached. Apache can only redirect a request that actually arrives.

Method B — Cloudflare Redirect Rules (no Apache config needed)

If DNS is on Cloudflare, you can handle the www redirect entirely at the Cloudflare edge — the request never reaches your server. Go to: Rules → Redirect Rules → Create rule

  • When: Hostname equals www.osztromok.com
  • Then: Static redirect → https://osztromok.com/ (301 Permanent)

This is faster (redirect happens at Cloudflare's edge) and requires no Apache changes. Either method works — use the Cloudflare approach if you're already comfortable there, or the Apache approach if you want all routing logic on the server.

Wildcard Subdomains

A wildcard matches any subdomain that doesn't have a more specific record. It's one DNS record that covers anything.osztromok.com.

Wildcard DNS record in Cloudflare

TypeNameTargetProxy
CNAME*osztromok.comOrange cloud (proxied)

This makes anything.osztromok.com resolve to Cloudflare's edge. Traffic still needs somewhere to land on the server.

Wildcards only match one level. *.osztromok.com matches foo.osztromok.com but not foo.bar.osztromok.com. If you need deeper subdomains, create explicit records for each.

Wildcard Apache vhost (catch-all for unknown subdomains)

# /etc/apache2/sites-available/wildcard.osztromok.com.conf # Serves a generic page for any subdomain without its own vhost <VirtualHost *:80> ServerName wildcard.osztromok.com ServerAlias *.osztromok.com # Use the server name in the document root so each subdomain # can have different content if you create matching directories DocumentRoot /var/www/html # or a generic "subdomain not configured" page <Directory /var/www/html> Options FollowSymLinks AllowOverride None Require all granted </Directory> ErrorLog ${APACHE_LOG_DIR}/wildcard_error.log CustomLog ${APACHE_LOG_DIR}/wildcard_access.log combined </VirtualHost>
Order matters. The wildcard vhost should load after all specific vhosts alphabetically (or use a z- prefix in the filename). Specific vhosts — blog.osztromok.com.conf, shop.osztromok.com.conf — match first. The wildcard only catches subdomains with no explicit vhost.

Scenario 3 — Reverse Proxy to a Backend App

When you have an application (Python/FastAPI, Node.js, anything) running on a local port (e.g. 8080), Apache can act as a reverse proxy: it accepts the subdomain request and forwards it to the app, then passes the response back to the visitor. The app doesn't need to know about Cloudflare or subdomains at all.

Reverse proxy flow: Visitor → https://api.osztromok.com │ ▼ Cloudflare Tunnel → Apache *:80, Host: api.osztromok.com │ ▼ Apache matches api.osztromok.com vhost → ProxyPass │ ▼ Forwards internally to http://localhost:8080 │ ▼ FastAPI / Node / Flask running on port 8080 │ Response flows back: app → Apache → Cloudflare → Visitor
# Enable the required Apache modules (one-time setup) $ sudo a2enmod proxy proxy_http proxy_balancer lbmethod_byrequests $ sudo systemctl restart apache2 # restart needed for new modules, not just reload
# /etc/apache2/sites-available/api.osztromok.com.conf <VirtualHost *:80> ServerName api.osztromok.com # Forward all requests to the backend app on port 8080 ProxyPreserveHost On ProxyPass / http://localhost:8080/ ProxyPassReverse / http://localhost:8080/ ErrorLog ${APACHE_LOG_DIR}/api_error.log CustomLog ${APACHE_LOG_DIR}/api_access.log combined </VirtualHost>
ProxyPreserveHost On
Passes the original Host: api.osztromok.com header to the backend app. Without this, the app sees Host: localhost:8080, which can break apps that construct absolute URLs.
ProxyPass /
Forward all requests (/) to localhost:8080. A request for /users/42 becomes localhost:8080/users/42.
ProxyPassReverse /
Rewrites Location: headers in redirect responses from the backend — so the browser sees https://api.osztromok.com/... rather than http://localhost:8080/....
$ sudo a2ensite api.osztromok.com.conf $ sudo apache2ctl configtest && sudo systemctl reload apache2 # Test — the curl response should come from your backend app $ curl -H "Host: api.osztromok.com" http://localhost/health {"status": "ok"} ← response from FastAPI/Node running on :8080

Subdomain Deployment Checklist

Use this every time you add a new subdomain. Work top-to-bottom — each step depends on the previous one.

New Subdomain Checklist — print or tick as you go
Document root createdsudo mkdir -p /var/www/subdomain.osztromok.com/public_html with correct ownership (philip:www-data) and permissions (755 dirs, 644 files).
Content placed — at minimum an index.html in the document root, so a successful hit returns visible content rather than a 403/directory listing.
Apache vhost config written/etc/apache2/sites-available/subdomain.osztromok.com.conf with correct ServerName, DocumentRoot, Directory block (AllowOverride + Require all granted), and separate log files.
Modules enabled if neededsudo a2enmod rewrite for .htaccess rewrites; sudo a2enmod proxy proxy_http for reverse proxy; restart Apache after enabling modules.
Site enabledsudo a2ensite subdomain.osztromok.com.conf to create the symlink in sites-enabled.
Config validatedsudo apache2ctl configtest returns "Syntax OK" before reloading. Never skip this step.
Apache reloadedsudo systemctl reload apache2 — not restart.
Local test passescurl -H "Host: subdomain.osztromok.com" http://localhost returns the correct content. This confirms Apache is working before DNS is involved.
DNS record created — CNAME in Cloudflare (proxied/orange cloud) pointing subdomainosztromok.com, OR new public hostname added to the Cloudflare Tunnel config.
DNS verifieddig subdomain.osztromok.com +short returns a Cloudflare IP (not NXDOMAIN, not your home IP).
External test passescurl https://subdomain.osztromok.com from outside the local network (or via mobile data) returns the correct content with 200 OK.
Main site unaffectedcurl -H "Host: osztromok.com" http://localhost still returns the correct main site content. Adding a new vhost should never disturb existing ones.

Troubleshooting

DNS_PROBE_FINISHED_NXDOMAIN — "This site can't be reached"
The DNS record doesn't exist or hasn't propagated yet. Check: dig subdomain.osztromok.com +short — if it returns nothing, the record is missing from Cloudflare DNS (or you only added the public hostname to the tunnel but not the DNS record, and the tunnel didn't create one automatically). Also check: if using Cloudflare Tunnel via the dashboard GUI, the public hostname does auto-create the DNS record. If using the CLI, run cloudflared tunnel route dns tunnel-name subdomain.osztromok.com manually.
DNS resolves but serves the wrong site (main site content instead of subdomain)
DNS is working but the Apache vhost isn't. Run sudo apache2ctl -S and check whether the subdomain vhost appears in the list. If not, it's not enabled — run sudo a2ensite subdomain.conf and reload. If it is listed but still wrong, check ServerName in the config matches exactly what's in the Host header (including capitalisation, which Apache treats case-insensitively but typos matter).
SSL error — "NET::ERR_CERT_COMMON_NAME_INVALID" or similar
The subdomain's DNS isn't proxied through Cloudflare (grey cloud instead of orange). Cloudflare's shared SSL certificate only covers proxied subdomains. Fix: in Cloudflare DNS, click the cloud icon next to the subdomain record and switch from grey (DNS only) to orange (proxied). Wait a moment and refresh.
Reverse proxy returns 502 Bad Gateway
Apache can reach the ProxyPass destination but the backend isn't responding. Check: (1) is the app actually running? ss -tlnp | grep 8080 — if nothing's listening on that port, start the app. (2) Is it listening on 127.0.0.1 or 0.0.0.0? The app must accept connections from localhost. (3) Check the backend app's own logs for errors.
Wildcard subdomain works but specific subdomains with their own vhost serve the wildcard content instead
The wildcard vhost is loading before the specific vhost. This happens when the wildcard filename sorts alphabetically before the specific vhost (e.g. a-wildcard.conf before blog.conf). Fix: rename the wildcard config to something that sorts last, like z-wildcard.conf, then run a2dissite old-name.conf && a2ensite z-wildcard.conf and reload.

Quick Reference — Chapter 6

TaskAction
New static subdomainCreate dir → write vhost → a2ensite → configtest → reload → test locally → add DNS → test externally
Redirect www → apexVhost with Redirect permanent / https://apex.com/ OR Cloudflare Redirect Rule
Wildcard DNSCNAME record with Name: * → Target: osztromok.com in Cloudflare, proxied
Wildcard ApacheServerAlias *.osztromok.com in vhost, filename should sort last alphabetically
Reverse proxya2enmod proxy proxy_http → restart → vhost with ProxyPreserveHost + ProxyPass + ProxyPassReverse
Test vhost locallycurl -H "Host: sub.osztromok.com" http://localhost
Verify DNS livedig sub.osztromok.com +short → should return Cloudflare IP
Check vhost ordersudo apache2ctl -S → lists all vhosts in match priority order
SymptomLikely cause
NXDOMAIN / "can't be reached"DNS record missing in Cloudflare, or not propagated yet
Wrong site content servedApache vhost not enabled, or ServerName mismatch
SSL certificate errorDNS not proxied (grey cloud) — switch to orange cloud in Cloudflare
502 Bad GatewayBackend app not running on the port specified in ProxyPass
Wildcard catches specific subdomainWildcard vhost sorting before specific one — rename to z- prefix