Two settings decide whether this works: the proxy status on the DNS record, and the SSL/TLS encryption mode on the zone. Get those wrong and you get a redirect loop or a 5xx error page that looks like your server is down when it is not.
This assumes your domain already uses Cloudflare’s nameservers and shows as Active in the dashboard.
What this sets up
Visitors resolve app.example.com to a Cloudflare edge IP. Cloudflare
terminates TLS, applies caching and WAF rules, then opens a second TLS
connection to your VPS. Your origin IP is not in public DNS, and your firewall
rejects anyone who tries to reach it directly.
Not covered here: Cloudflare Tunnel (which removes the need for inbound ports entirely), load balancing across several origins, or Workers in front of the origin.
Create the DNS record
In DNS → Records, add:
| Field | Value |
|---|---|
| Type | A |
| Name | app (or @ for the apex) |
| IPv4 address | your VPS public address, e.g. 203.0.113.42 |
| Proxy status | Proxied |
| TTL | Auto |
Add an AAAA record too if your VPS has IPv6. Cloudflare answers over both
regardless, but a real AAAA record lets IPv6 clients reach the origin if you
ever turn the proxy off.
TTL is greyed out on proxied records and that is expected. Cloudflare’s edge is already answering for the hostname, so there is nothing meaningful to cache downstream and changes take effect within seconds. On DNS-only records the TTL applies normally — drop it to a low value a day before a migration, not on the day itself.
The apex is a special case. CNAME example.com -> something.example.net is not
valid DNS, but Cloudflare resolves the target and answers with its addresses
instead, so an apex CNAME works here where it would not elsewhere. That is
CNAME flattening, and it is on by default for proxied records.
The same record over the API:
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"type": "A",
"name": "app",
"content": "203.0.113.42",
"ttl": 1,
"proxied": true
}'"ttl": 1 means Auto. Use a scoped API token with Zone.DNS: Edit on that one
zone — not a global API key.
Confirm the proxy is actually in front
A proxied record resolves to Cloudflare, never to your server:
dig +short app.example.com104.21.44.17
172.67.191.203Those are Cloudflare addresses. If dig returns 203.0.113.42, the record is
DNS-only and the orange cloud is off.
Set the encryption mode to Full (strict)
Go to SSL/TLS → Overview and select Full (strict).
The modes behave very differently:
- Flexible — Cloudflare talks to your origin over plain HTTP. Any HTTPS redirect on your server becomes an infinite loop. Do not use it.
- Full — Cloudflare uses HTTPS to the origin but accepts any certificate, including self-signed and expired ones. Encrypted, not authenticated.
- Full (strict) — HTTPS with certificate validation. This is the setting you want.
Full (strict) requires a certificate on your origin that Cloudflare trusts: either a publicly trusted certificate such as Let’s Encrypt, or a Cloudflare Origin CA certificate.
Install a Cloudflare Origin CA certificate
Origin CA certificates are free, last up to 15 years, and are trusted only by Cloudflare. That is exactly the right trade for an origin that should never be reached directly.
- In SSL/TLS → Origin Server, choose Create Certificate.
- Keep the generated private key option, list the hostnames
(
example.com,*.example.com), and create. - Copy the certificate and the key — the key is shown once and never again.
sudo install -d -m 0750 -o root -g root /etc/ssl/cloudflare
sudo nano /etc/ssl/cloudflare/origin.pem # paste the certificate
sudo nano /etc/ssl/cloudflare/origin.key # paste the private key
sudo chmod 640 /etc/ssl/cloudflare/origin.pem
sudo chmod 600 /etc/ssl/cloudflare/origin.keyserver {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/ssl/cloudflare/origin.pem;
ssl_certificate_key /etc/ssl/cloudflare/origin.key;
# Log the visitor's address, not Cloudflare's.
real_ip_header CF-Connecting-IP;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}sudo nginx -t && sudo systemctl reload nginxLock the origin to Cloudflare
With the proxy on, nothing except Cloudflare should be allowed to reach ports 80 and 443.
#!/usr/bin/env bash
set -euo pipefail
for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) \
$(curl -fsS https://www.cloudflare.com/ips-v6); do
ufw allow proto tcp from "$ip" to any port 80,443 comment 'cloudflare'
done
ufw reloadsudo chmod 700 /usr/local/sbin/ufw-cloudflare.sh
sudo /usr/local/sbin/ufw-cloudflare.sh
sudo ufw status numbered | head -20Cloudflare changes these ranges occasionally. Re-run the script from a monthly
cron job, and delete the old comment 'cloudflare' rules first so the list does
not grow without bound.
Keep SSH reachable. Allow port 22 from your own address, or from anywhere if you have key-only authentication and fail2ban, but never let this script be the only thing standing between you and a locked-out server.
Common errors
Error 521 — web server is down. Cloudflare reached your IP and the
connection was refused. Either nothing is listening on 443, or your firewall
dropped Cloudflare after you ran the script above. Check
sudo ss -tlnp | grep :443.
Error 522 — connection timed out. Packets are being dropped rather than
refused. Almost always a firewall: ufw, a cloud provider security group, or
both. Test from outside with curl -v --connect-timeout 5 https://203.0.113.42.
Error 525 — SSL handshake failed. You are on Full or Full (strict) and the origin’s TLS is broken — wrong certificate path, key mismatch, or nginx serving a default HTTP-only vhost on 443.
Error 526 — invalid SSL certificate. Full (strict) specifically. The origin
certificate is expired, self-signed, or does not cover the hostname. An Origin
CA certificate generated for example.com alone will not validate for
app.example.com; include the wildcard.
ERR_TOO_MANY_REDIRECTS. Flexible mode plus an HTTPS redirect on the origin. Switch to Full (strict).
Every request logs Cloudflare’s IP. Add set_real_ip_from entries for the
Cloudflare ranges, or the equivalent RemoteIPTrustedProxy in Apache and
trustedIPs in Traefik.
Production notes
Turn on Always Use HTTPS in SSL/TLS → Edge Certificates so Cloudflare handles the http-to-https redirect at the edge, then remove the redirect from nginx. One redirect is enough.
Enable Authenticated Origin Pulls if you want the origin to verify that the connection came from Cloudflare using a client certificate, rather than trusting the IP allowlist alone. It is a strictly stronger control and takes ten minutes.
Do not leave a DNS-only record pointing at the same IP — a direct.example.com
or a stale mail record leaks the origin address and undoes the firewall work.
Check every record in the zone, including ones you did not create.