A public HTTPS address with no open ports

Subhankar Denria
Software Architect · Product Engineer
What this part does
Give the API a public HTTPS address — api.example.com — that the app can call, without opening any door on the server. Then put a free rate limit in front of it.
- Cost
- $0 — the tunnel and one free rule
- Open ports
- 0
- If you skip a step
- An API anyone can knock on — or a text bill a stranger runs up
How a tunnel works, in plain words
Normally, a web server waits for visitors to connect in to it. That means an open port, a public address, and a firewall rule — and anyone on the internet can knock.
A Cloudflare Tunnel flips it around:
- 1A small program on the server,
cloudflared, connects out to Cloudflare and keeps that connection open. - 2A visitor asks for
https://api.example.com. The request goes to Cloudflare, like any website on Cloudflare. - 3Cloudflare sends the request back down the connection the server opened, to nginx on
127.0.0.1:8080. - 4The answer goes back up the same way.
A visitor
asks for https://api.example.com
Cloudflare
certificate, rate limit — the only address anyone sees
cloudflared → nginx on 127.0.0.1:8080
dialled OUT to Cloudflare and keeps the line open
Open ports: 0 · Address: never published
The consequences are all good:
- No inbound ports at all. The firewall from post 3 stays at "deny everything" except SSH from Google's relay.
- The server's address is never published. Nobody can scan or attack it directly; they only ever see Cloudflare.
- HTTPS is handled by Cloudflare, certificate and all. Nothing to renew.
- Rebuilding the server changes nothing public. Connect the new server to the same tunnel and the address keeps working.
It's free, and it works over IPv6 — with one setting.
Step 1 — Create the tunnel
In the Cloudflare dashboard, at account level (not inside your domain — click back to the account if you're in it): Networking → Tunnels. On older menus it's Zero Trust → Networks → Tunnels, which first asks you to pick a team name and the free plan.
Why account level? Tunnels belong to your account, not to a domain, so they aren't in the domain's menu. I spent a while looking there.
Create a tunnel → Cloudflared → name it lampsill-api.
Step 2 — Connect the server
The next screen shows install instructions. Choose Debian · 64-bit. Skip "Install cloudflared" — provision.sh already did. Copy the Install as service command with its copy icon. It looks like:
sudo cloudflared service install eyJhIjoi… # a very long tokenRun it in the SSH window, then restart and check:
sudo systemctl daemon-reload && sudo systemctl restart cloudflared; sleep 8
systemctl is-active cloudflared
sudo journalctl -u cloudflared -n 30 --no-pager | grep -iE "registered|error|fail" | tail -6You want active, and four lines saying "Registered tunnel connection", to addresses starting 2606:4700: — Cloudflare's IPv6. The dashboard then says "Tunnel connected successfully".
The IPv6 setting
Out of the box, cloudflared may try to reach Cloudflare over IPv4 — which this server doesn't have. provision.sh leaves a small systemd "drop-in" file that changes that without touching the service itself:
# /etc/systemd/system/cloudflared.service.d/ipv6.conf
[Service]
Environment=TUNNEL_EDGE_IP_VERSION=autoauto lets it use whichever address family works. Because it's a separate file, it survives reinstalling the service (which we'll do in step 6). That's why the restart above matters: it applies this setting.
Harmless warnings: yellow lines mentioning ping_group_range or "ICMP proxy is disabled" are about forwarding ping through private networks, which we don't use. Ignore them.
⚠️ Mind the token
That install command contains the tunnel's token, and the terminal echoes it in full. Anyone who has it can run their own cloudflared with your tunnel, and Cloudflare would send them a share of your API's traffic — including people's logins.
Type clear before taking any screenshot of the terminal. I didn't, the token appeared in a screenshot, and I rotated it (step 6). Rotating is easy — but only if you know you need to.
Step 3 — Publish it as api.example.com
On the tunnel's page, the Routes box → Add route → Published application:
| Field | Value |
|---|---|
| Subdomain | api |
| Domain | your domain |
| Path | (empty) |
| Service URL | http://127.0.0.1:8080 |
Subdomain
- Value
api
Domain
- Value
- your domain
Path
- Value
- (empty)
Service URL
- Value
http://127.0.0.1:8080
→ Add route. Cloudflare creates the DNS record itself.
Type http://, not https://. It feels wrong, but it's correct: nginx speaks plain HTTP on the server's own loopback, a connection that never leaves the machine. Visitors still get HTTPS from Cloudflare. Putting https:// here gives a 502 Bad Gateway.
If you skipped this screen during setup, the tunnel list shows "Routes: No routes" — mine did.
Step 4 — Test it from your laptop
curl -s https://api.example.com/up -o /dev/null -w '%{http_code}\n'200 means the API is on the internet, with HTTPS, and no port is open. That moment is worth savouring.
Step 5 — Always use HTTPS
Your domain → SSL/TLS → Edge Certificates → Always Use HTTPS: On.
Why: otherwise http://api.example.com also answers, unencrypted. With it on, plain HTTP gets a 301 redirect to HTTPS. It only affects hostnames that go through Cloudflare — my website points at another host directly, so it wasn't touched.
Step 6 — Rotating the token
Whenever the token might have been seen, make the old one useless. The API stays up throughout: the running connector keeps its connection until restarted.
- 1Networking → Tunnels → your tunnel → the Refresh token card → Rotate token → confirm.
- 2+ Add a replica → Debian · 64-bit → copy Install as service. ("Add a replica" is just where Cloudflare shows the command with the new token. You're not adding a second server.)
- 3On the server:
sudo cloudflared service uninstall
# paste the new install command here, then:
clear; sudo systemctl daemon-reload && sudo systemctl restart cloudflared; sleep 8; systemctl is-active cloudflaredclear wipes the echoed token off the screen. If it says active after a restart, it must be using the new token — the old one no longer works.
Step 7 — A free rate limit
A rate limit turns away anyone sending too many requests. There are two different jobs here, and it's worth separating them.
Job 1: stop abuse that costs money — inside the app
Some endpoints spend real money: anything that sends a text or places a call costs something every single time it runs. Those limits belong inside the app, where it knows who is asking, not just which address. In Laravel it's one line per route:
// "throttle:5,1" = 5 requests per 1 minute — counted per user once they're
// signed in, per address before that. (An example: not Lampsill's routes or numbers.)
Route::post('/example/send-text', …)->middleware('throttle:5,1');How strict to be depends on what the endpoint does:
| Kind of endpoint | Limit | Why |
|---|---|---|
| Signing in | A few tries a minute | Slows down anyone guessing passwords |
| Anything that sends a text or places a call | Strict, and per user | Every request costs money. A loose limit here is a bill a stranger can run up for you |
| "I'm OK, cancel the alert" | None, deliberately | A safety app must never tell someone to wait before saying they're fine |
Signing in
- Limit
- A few tries a minute
- Why
- Slows down anyone guessing passwords
Anything that sends a text or places a call
- Limit
- Strict, and per user
- Why
- Every request costs money. A loose limit here is a bill a stranger can run up for you
"I'm OK, cancel the alert"
- Limit
- None, deliberately
- Why
- A safety app must never tell someone to wait before saying they're fine
One limit the app can't set for you: the text and calling provider's own. Give it a spending cap, and switch on only the countries you actually send to. If everything above ever fails, that's what stops the bill.
Job 2: stop floods — at Cloudflare
Cloudflare only sees addresses. Its job is protecting the small server from someone hammering it, before the request ever arrives. The free plan allows one rate-limiting rule, counted per IP address, over a fixed 10-second window, with a fixed 10-second block.
Your domain → Security → Security rules → Create rule → Rate limiting rule:
| Field | Value | Why |
|---|---|---|
| Rule name | api-flood-guard | |
| When incoming requests match | URI Path · starts with · /api/v1/ | Every API call |
| With the same characteristics | IP | The only option on the free plan |
| When rate exceeds | 50 requests per 10 seconds | 5 per second, sustained — far above anything a real phone sends, even while it's retrying an alert |
| Then take action | Block | Answers 429 Too Many Requests, which apps treat as "try again shortly" |
| For duration | 10 seconds | The only option on the free plan — short enough that a mistaken block barely matters |
Rule name
- Value
api-flood-guard
When incoming requests match
- Value
- URI Path · starts with ·
/api/v1/ - Why
- Every API call
With the same characteristics
- Value
- IP
- Why
- The only option on the free plan
When rate exceeds
- Value
- 50 requests per 10 seconds
- Why
- 5 per second, sustained — far above anything a real phone sends, even while it's retrying an alert
Then take action
- Value
- Block
- Why
- Answers
429 Too Many Requests, which apps treat as "try again shortly"
For duration
- Value
- 10 seconds
- Why
- The only option on the free plan — short enough that a mistaken block barely matters
The limit is generous on purpose. For a safety app, the worst outcome isn't a flood; it's a real alert being blocked. A flood guard should only ever catch things no human-driven app would do.
Testing it
Sending requests one after another from India took about a second each — nowhere near the limit. To test properly, send them in parallel:
# 150 requests, 30 at a time, to any endpoint that needs signing in
for i in $(seq 1 150); do
printf 'url = "https://api.example.com/api/v1/your-endpoint"\noutput = "/dev/null"\nwrite-out = "%%{http_code}\\n"\nheader = "Accept: application/json"\n\n'
done > burst.cfg
curl -s -Z --parallel-max 30 -K burst.cfg | sort | uniq -cResult, in 2 seconds:
74 401 ← got through (401 = not signed in, which is correct)
76 429 ← blocked by CloudflareA few more than 50 get through because Cloudflare's counting catches up a moment late. Ten seconds later, normal requests worked again.
0
got through · 401
not signed in — the correct answer
0
blocked · 429
turned away by Cloudflare
Each square is one request · rule: 50 per 10 seconds
The bug the test found
My first test run returned 500 Internal Server Error for every request, not 401. Here's why:
- My first
curldidn't sendAccept: application/json. - Laravel's login check, seeing a request that didn't ask for JSON, assumed a browser and tried to redirect it to a login page,
route('login'). - This API has no login page. Looking it up threw an error — a 500 — before the app's own "please sign in again" response could run.
The real app always sends the JSON header, so users never saw it. But every bot or scanner without the header would have caused a 500 and written an error into the log. The fix is one line in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
// There is no login page — this server is only an API.
$middleware->redirectGuestsTo(fn () => null);
})With nowhere to redirect to, the request reaches the app's normal handler and gets a proper 401 in JSON. I added a test for the no-header case first, watched it fail with the same error, then made the fix and watched all 141 tests pass. Testing one thing found another — which is why you test.
What you should see
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/up # 200
curl -s -o /dev/null -w '%{http_code}\n' http://api.example.com/up # 301
curl -s https://api.example.com/api/v1/your-endpoint # {"error":…} with 401The API is public, encrypted and protected, and nothing on the server is open to the internet.
Next up · Part 7 of 9 · 9 min read
The alarm that notices silence
If the every-minute jobs ever stop — server down, code broken, disk full, Google switched it off — get an email within about six minutes, instead of finding out when someone wasn't checked on. It's the most important post in the series.
Keep going Part 5: Laravel + Postgres on 1 GB of memoryWritten by
Subhankar Denria
Software Architect · 25+ products shipped