01Services02Process03Projects04About05FAQ06Blog07Hire Me

25+ products shipped · $3.8M+ raised by clients

Back to Blog
InfrastructurePart 6 of 9September 28, 202612 min read

A public HTTPS address with no open ports

Subhankar Denria

Subhankar Denria

Software Architect · Product Engineer

~15 min

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:

  1. 1A small program on the server, cloudflared, connects out to Cloudflare and keeps that connection open.
  2. 2A visitor asks for https://api.example.com. The request goes to Cloudflare, like any website on Cloudflare.
  3. 3Cloudflare sends the request back down the connection the server opened, to nginx on 127.0.0.1:8080.
  4. 4The answer goes back up the same way.
Who opens the connection

A visitor

asks for https://api.example.com

▼
HTTPS

Cloudflare

certificate, rate limit — the only address anyone sees

▼
sent back DOWN the line the server opened

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:

bash
sudo cloudflared service install eyJhIjoi…   # a very long token

Run it in the SSH window, then restart and check:

bash
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 -6

You 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:

ini
# /etc/systemd/system/cloudflared.service.d/ipv6.conf
[Service]
Environment=TUNNEL_EDGE_IP_VERSION=auto

auto 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:

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

bash
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.

  1. 1Networking → Tunnels → your tunnel → the Refresh token card → Rotate token → confirm.
  2. 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.)
  3. 3On the server:
bash
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 cloudflared

clear 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:

php
// "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:

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:

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:

bash
# 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 -c

Result, in 2 seconds:

text
  74 401    ← got through (401 = not signed in, which is correct)
  76 429    ← blocked by Cloudflare

A few more than 50 get through because Cloudflare's counting catches up a moment late. Ten seconds later, normal requests worked again.

150 requests in 2 seconds

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 real test's result. Ten seconds later, normal requests worked again.

The bug the test found

My first test run returned 500 Internal Server Error for every request, not 401. Here's why:

  • My first curl didn't send Accept: 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:

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

bash
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 401

The API is public, encrypted and protected, and nothing on the server is open to the internet.

Let's connect

Choose your preferred way

Available for new projects