Implementing Cloudflare with a mini suitcase server node that is mobile.

Implementing Cloudflare with a mini suitcase server node that is mobile.

Below is a complete, production-ready starter you can drop into your stack. It hides your ASP.NET Core app behind Cloudflare, restores real client IPs, verifies Cloudflare Access JWTs, and boots cloudflared as a daemon. I listed every file and fully regenerated code (with comments) per your rules.

File list & full code​

  1. infrastructure/cloudflared/config.yml

Purpose: declarative Tunnel config. One tunnel, two services: web (HTTPS) and SSH (TCP). mTLS to CF, no inbound ports on your host.​

tunnel: membership-core
credentials-file: /etc/cloudflared/membership-core.json

ingress:

Public hostname → local service​

  • hostname: app.yourdomain.com
    service: http://localhost:5000
    originRequest:
    http2Origin: true # Enable HTTP/2 between CF edge and origin (via tunnel)
    noTLSVerify: true # If you terminate TLS at CF and run HTTP to Kestrel locally
    connectTimeout: 10s
    keepAliveTimeout: 1m
    disableChunkedEncoding: false
    httpHostHeader: app.yourdomain.com

Optional: TCP/SSH over Tunnel via Cloudflare Access SSH​

  • hostname: ssh.yourdomain.com
    service: ssh://localhost:22
  • service: http_status:404
warp-routing:
enabled: false

metrics: 127.0.0.1:40657
loglevel: info
protocol: quic # Faster, resilient edge↔tunnel transport

  1. infrastructure/cloudflared/cloudflared.service

Purpose: systemd unit to run the daemon at boot and auto-restart.​

[Unit]
Description=Cloudflare Tunnel daemon
After=network-online.target
Wants=network-online.target

[Service]
User=cloudflared
Group=cloudflared
Environment=LOGNAME=cloudflared
ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
CapabilityBoundingSet=
AmbientCapabilities=
CacheDirectory=cloudflared

[Install]
WantedBy=multi-user.target

  1. infrastructure/cloudflare/zero-trust-access-policy.md

Purpose: define who can hit app.yourdomain.com before it ever reaches your origin.​

  • Application: app.yourdomain.com
  • Policy: “Members Only”
  • Decision: Allow if ALL:
    • Identity Provider group: Members (Okta/AzureAD/Google Workspace)
    • Device posture: (optional) WARP healthy AND OS ≠ rooted AND Disk encrypted
    • Country: NOT (CN, RU, KP, IR) # adjust to taste
  • Session duration: 12h
  • JWT audience (aud): urn:manoffocus:membership
  • JWT issuers: https://<your-team>.cloudflareaccess.com
  • Headers added by CF edge:
    • Cf-Access-Jwt-Assertion: <JWT>
    • Cf-Connecting-Ip: <client-ip>
    • Cf-Ipcountry: <country>
    • Cf-Ray: <trace id>
  1. docker-compose.yml

Purpose: local/dev orchestration: your app + cloudflared (optional for dev).​

version: "3.9"
services:
web:
build: ./src
ports:
- "5000:8080" # Kestrel inside container on 8080
environment:
- ASPNETCORE_URLS=http://0.0.0.0:8080
- ACCESS_AUD=urn:manoffocus:membership
- ACCESS_ISS=https://yourteam.cloudflareaccess.com
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run --config /etc/cloudflared/config.yml
volumes:
- ./infrastructure/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
- ./infrastructure/cloudflared/creds.json:/etc/cloudflared/membership-core.json:ro
depends_on:
- web

  1. src/Program.cs
    // Purpose: minimal ASP.NET Core 8 app wiring Cloudflare headers, Access JWT validation, and a health endpoint.
    using Microsoft.AspNetCore.HttpOverrides;
    using Microsoft.IdentityModel.Tokens;
    using System.IdentityModel.Tokens.Jwt;
    using System.Security.Claims;
    using System.Text;
var builder = WebApplication.CreateBuilder(args);

// 1) Forwarded headers to restore real client IP from Cloudflare
builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
// Cloudflare sets CF-Connecting-IP; also provides standard X-Forwarded-For/Proto
opts.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// (Optional) trust loopback since tunnel terminates locally
opts.KnownProxies.Clear();
opts.KnownNetworks.Clear();
});

// 2) Cloudflare Access JWT validator service (see AccessJwtValidator)
builder.Services.AddSingleton<AccessJwtValidator>();

builder.Services.AddControllers();

var app = builder.Build();

app.UseForwardedHeaders();

// 3) Middleware: validate Cloudflare Access JWT on all protected routes
app.Use(async (ctx, next) =>
{
// Allow health and static files without auth if you want
var path = ctx.Request.Path.Value?.ToLowerInvariant() ?? "";
if (path.StartsWith("/health") || path.StartsWith("/public"))
{
await next();
return;
}


<span><span><span>// Cloudflare sends JWT in Cf-Access-Jwt-Assertion</span></span><span><br></span><span><span>var</span></span><span> jwt = ctx.Request.Headers[</span><span><span>"Cf-Access-Jwt-Assertion"</span></span><span>].ToString();<br></span><span><span>if</span></span><span> (</span><span><span>string</span></span><span>.IsNullOrEmpty(jwt))<br>{<br> ctx.Response.StatusCode = </span><span><span>401</span></span><span>;<br> </span><span><span>await</span></span><span> ctx.Response.WriteAsync(</span><span><span>"Missing Cloudflare Access token."</span></span><span>);<br> </span><span><span>return</span></span><span>;<br>}<br><br></span><span><span>var</span></span><span> validator = ctx.RequestServices.GetRequiredService&lt;AccessJwtValidator&gt;();<br></span><span><span>var</span></span><span> result = </span><span><span>await</span></span><span> validator.ValidateAsync(jwt);<br><br></span><span><span>if</span></span><span> (!result.IsValid)<br>{<br> ctx.Response.StatusCode = </span><span><span>401</span></span><span>;<br> </span><span><span>await</span></span><span> ctx.Response.WriteAsync(</span><span><span>"Invalid Cloudflare Access token."</span></span><span>);<br> </span><span><span>return</span></span><span>;<br>}<br><br></span><span><span>// Attach principal for app-level authorization</span></span><span><br>ctx.User = </span><span><span>new</span></span><span> ClaimsPrincipal(result.ClaimsIdentity);<br><br></span><span><span>await</span></span><span> next();<br></span></span>
});

app.MapControllers();

app.MapGet("/", () => "Membership core behind Cloudflare Tunnel — OK");
app.MapGet("/health/live", () => Results.Ok(new { status = "live" }));
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));

app.Run();

  1. src/Services/AccessJwtValidator.cs
    // Purpose: verify the CF Access JWT (issuer/audience, signature). Uses JWKS fetch + cache.
    using Microsoft.IdentityModel.Tokens;
    using System.IdentityModel.Tokens.Jwt;
    using System.Security.Claims;
    using System.Net.Http.Json;
public sealed class AccessJwtValidator
{
private readonly HttpClient _http = new HttpClient();
// Configure via env or appsettings
private readonly string _issuer = Environment.GetEnvironmentVariable("ACCESS_ISS") ?? "https://yourteam.cloudflareaccess.com";
private readonly string _aud = Environment.GetEnvironmentVariable("ACCESS_AUD") ?? "urn:manoffocus:membership";
private JsonWebKeySet? _jwks;
private DateTime _jwksFetchedAt = DateTime.MinValue;



<span><span><span>public</span></span><span> </span><span><span>async</span></span><span> Task&lt;(</span><span><span>bool</span></span><span> IsValid, ClaimsIdentity ClaimsIdentity)&gt; ValidateAsync(</span><span><span>string</span></span><span> jwt)<br>{<br> </span><span><span>try</span></span><span><br> {<br> </span><span><span>var</span></span><span> handler = </span><span><span>new</span></span><span> JwtSecurityTokenHandler();<br> </span><span><span>var</span></span><span> parameters = </span><span><span>new</span></span><span> TokenValidationParameters<br> {<br> ValidIssuer = _issuer,<br> ValidateIssuer = </span><span><span>true</span></span><span>,<br> ValidateIssuerSigningKey = </span><span><span>true</span></span><span>,<br> ValidateAudience = </span><span><span>true</span></span><span>,<br> ValidAudience = _aud,<br> ValidateLifetime = </span><span><span>true</span></span><span>,<br> ClockSkew = TimeSpan.FromMinutes(</span><span><span>2</span></span><span>),<br> IssuerSigningKeys = </span><span><span>await</span></span><span> GetSigningKeysAsync()<br> };<br><br> </span><span><span>var</span></span><span> principal = handler.ValidateToken(jwt, parameters, </span><span><span>out</span></span><span> _);<br> </span><span><span>return</span></span><span> (</span><span><span>true</span></span><span>, (ClaimsIdentity)principal.Identity!);<br> }<br> </span><span><span>catch</span></span><span><br> {<br> </span><span><span>return</span></span><span> (</span><span><span>false</span></span><span>, </span><span><span>new</span></span><span> ClaimsIdentity());<br> }<br>}<br><br></span><span><span>private</span></span><span> </span><span><span>async</span></span><span> Task&lt;IEnumerable&lt;SecurityKey&gt;&gt; GetSigningKeysAsync()<br>{<br> </span><span><span>// Refresh JWKS every 12h</span></span><span><br> </span><span><span>if</span></span><span> (_jwks == </span><span><span>null</span></span><span> || (DateTime.UtcNow - _jwksFetchedAt) &gt; TimeSpan.FromHours(</span><span><span>12</span></span><span>))<br> {<br> </span><span><span>var</span></span><span> jwksUrl = </span><span><span>$"<span>{_issuer}</span></span></span><span>/cdn-cgi/access/certs";<br> _jwks = </span><span><span>await</span></span><span> _http.GetFromJsonAsync&lt;JsonWebKeySet&gt;(jwksUrl);<br> _jwksFetchedAt = DateTime.UtcNow;<br> }<br> </span><span><span>return</span></span><span> _jwks!.Keys;<br>}<br></span></span>
}

  1. src/Middleware/CloudflareRealIpMiddleware.cs (optional, if you prefer explicit CF header handling)
    // Purpose: if you want to read CF-Connecting-IP specifically and stamp HttpContext.Connection.RemoteIpAddress.
    using System.Net;
public class CloudflareRealIpMiddleware
{
private readonly RequestDelegate _next;



<span><span><span><span>public</span></span></span><span> </span><span><span>CloudflareRealIpMiddleware</span></span><span>(</span><span><span>RequestDelegate next</span></span><span>) =&gt; _next = next;<br><br></span><span><span><span>public</span></span></span><span> </span><span><span>async</span></span><span> Task </span><span><span>Invoke</span></span><span>(</span><span><span>HttpContext context</span></span><span>)<br>{<br> </span><span><span>if</span></span><span> (context.Request.Headers.TryGetValue(</span><span><span>"CF-Connecting-IP"</span></span><span>, </span><span><span>out</span></span><span> </span><span><span>var</span></span><span> cfIp) &amp;&amp;<br> IPAddress.TryParse(cfIp.ToString(), </span><span><span>out</span></span><span> </span><span><span>var</span></span><span> ip))<br> {<br> context.Connection.RemoteIpAddress = ip;<br> }<br> </span><span><span>await</span></span><span> _next(context);<br>}<br></span></span>
}

// In Program.cs, add: app.UseMiddleware<CloudflareRealIpMiddleware>(); before auth.

  1. src/Controllers/MemberController.cs
    // Purpose: sample protected API endpoint returning identity and client IP.
    using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/member")]
public class MemberController : ControllerBase
{
[HttpGet("me")]
public IActionResult Me()
{
var name = User.Identity?.Name ?? "(no name claim)";
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return Ok(new
{
user = name,
claims = User.Claims.Select(c => new { c.Type, c.Value }),
clientIp = ip
});
}
}

  1. src/Dockerfile

Purpose: containerize the ASP.NET Core app for clean deployments behind Tunnel.​

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ./ ./
RUN dotnet restore
RUN dotnet publish -c Release -o /out

FROM base AS final
WORKDIR /app
COPY --from=build /out ./
ENTRYPOINT ["dotnet", "Membership.Core.dll"]

  1. src/appsettings.json
    {
    "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
    "AllowedHosts": "*"
    }
  2. README.md

Purpose: operator runbook.​

Bootstrapping Cloudflare Tunnel​

  1. Install cloudflared:
  2. Login & create tunnel:
    • cloudflared tunnel login
    • cloudflared tunnel create membership-core
    • This outputs a credentials JSON; place it at /etc/cloudflared/membership-core.json
  3. Configure DNS:
    • cloudflared tunnel route dns membership-core app.yourdomain.com
  4. Put config at /etc/cloudflared/config.yml (see file above).
  5. systemd:
    • useradd --system --home /etc/cloudflared --shell /usr/sbin/nologin cloudflared
    • chown -R cloudflared:cloudflared /etc/cloudflared
    • cp infrastructure/cloudflared/cloudflared.service /etc/systemd/system/
    • systemctl daemon-reload && systemctl enable --now cloudflared
  6. Verify:

Zero Trust (Access) policy​

  • Create an “Application” for app.yourdomain.com in Cloudflare Zero Trust.
  • Add an Access policy for your Members group and set AUD to urn:manoffocus:membership.
  • Save the team domain (ISS) and set env vars on your app host/container:

ASP.NET integration checklist​

  • Restore real client IPs: ForwardedHeaders + (optional) CloudflareRealIpMiddleware.
  • Trust only Cloudflare: make sure your firewall blocks inbound; only cloudflared dials out.
  • JWT verification: Cf-Access-Jwt-Assertion header → AccessJwtValidator.
  • WebSockets/SignalR: supported through Tunnel out of the box.
  • gRPC: works over HTTP/2 via Tunnel (http2Origin: true).
  • Health: /health/live and /health/ready for probes.
  • Logs: correlate with CF-Ray header for request tracing.

Design notes (plain English)​

  • “Edge node” vs “origin”: your server is not a Cloudflare POP, but with Tunnel + Access + WAF the edge makes decisions (identity, country, bot, rate-limit) and only then forwards to your private origin through encrypted outbound channels you control. That’s the secure, modern pattern.
  • Redundancy: you can run multiple cloudflared instances (same tunnel) on different boxes; CF will load-balance to healthy connections automatically.
  • Private services: you can add more hostnames (e.g., api.yourdomain.com, boardroom.yourdomain.com) in config.yml and route each to different local ports/containers.
  • Device posture (optional but strong): require WARP + posture checks for admins; members can stay identity-only.
  • Performance: protocol: quic is resilient; keep HTTP/2 to Kestrel for multiplexing.
  • Secrets: do not bake the Access issuer/audience into code in prod; use env or Key Vault.
If you want, I can extend this with:

  • CF Turnstile or Bot Management for signup/abuse control
  • Tiered caching rules for your images/videos
  • Web analytics + logs (Cloudflare Logpush to your SIEM)
  • A second tunnel for SSH/RDP bastion with per-user just-in-time access.
 
Back
Top