Hey you, welcome! Today I want to share something I put together while building hot_dog, a small Dioxus fullstack app that I host on Fly.io. The mission: expose Prometheus metrics on a separate port from the main application, without leaking them to the public internet. Sounds simple, right? Well, it involves a few moving pieces that I think are worth talking about.
The complete source is on GitHub. Let’s tear it apart.
The project
hot_dog is a Dioxus fullstack application. If you are not familiar with Dioxus, think of it as a React-inspired framework for Rust that covers web, desktop, mobile, and server-side rendering under one roof. The fullstack feature means the server side is powered by Axum — Dioxus wraps it up so you don’t always see it directly. But knowing it’s there opens some doors.
The goal here is simple: plug in Prometheus metrics via axum-prometheus and serve them on a dedicated port (9090) that Fly.io’s internal monitoring can scrape — without that port ever being publicly reachable. Security through network topology, not through passwords.
Dependencies
Let’s start with the Cargo.toml. The project uses Cargo feature flags to split desktop, web, and server builds cleanly:
1
2
3
4
5
6
7
8
9
10
11
12
[dependencies]
dioxus = { version = "0.7.3", features = ["router", "fullstack", "logger"] }
tokio = { version = "1.49.0", features = ["sync", "rt-multi-thread", "net"], optional = true }
axum = { version = "0.8", optional = true }
axum-prometheus = { version = "0.10.0", optional = true }
tracing-subscriber = { version = "0.3", optional = true }
[features]
default = []
web = []
desktop = []
server = ["libsql", "tokio", "base64", "axum-prometheus", "axum", "tracing-subscriber"]
Everything server-side — tokio, axum, axum-prometheus — lives behind the server feature gate. When you build for the web target, none of that compiles in. Clean separation that makes the binary for each target leaner.
The key crate here is axum-prometheus. It provides a PrometheusMetricLayerBuilder that you attach to an Axum router as middleware. It instruments every request passing through automatically and exposes a handle to render the metrics in Prometheus text format.
Two listeners
Here is the heart of it. The main function (only compiled when --features server is active) sets up two independent TCP listeners: one for the main application on port 8080, and one for metrics on port 9090.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[cfg(feature = "server")]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
tracing_subscriber::fmt::init();
use axum::{routing::get, Router};
use axum_prometheus::PrometheusMetricLayerBuilder;
let (prometheus_layer, metric_handle) = PrometheusMetricLayerBuilder::new()
.with_default_metrics()
.build_pair();
let metrics = Router::new()
.route("/metrics", get(|| async move { metric_handle.render() }));
let metrics_ip = std::env::var("HD_METRICS_IP").unwrap_or_else(|_| "0.0.0.0".to_string());
let metrics_port = std::env::var("HD_METRICS_PORT").unwrap_or_else(|_| "9090".to_string());
let metrics_listener =
tokio::net::TcpListener::bind(format!("{metrics_ip}:{metrics_port}")).await?;
let router = dioxus::server::router(app).layer(prometheus_layer);
let ip = std::env::var("IP").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let listener = tokio::net::TcpListener::bind(format!("{ip}:{port}")).await?;
tokio::try_join!(
axum::serve(listener, router),
axum::serve(metrics_listener, metrics)
)?;
Ok(())
}
PrometheusMetricLayerBuilder::build_pair() returns two things: a layer you attach to the router that captures metrics per request, and a handle you use elsewhere to render the accumulated data. They are linked internally through an Arc-wrapped registry — the layer writes, the handle reads.
The metrics router is dead simple: a single GET /metrics route that calls metric_handle.render(), which formats everything in Prometheus text exposition format.
The main router comes from Dioxus: dioxus::server::router(app). Dioxus builds an Axum Router under the hood, serving your SSR pages and any server functions. You just .layer(prometheus_layer) on top of it, and every HTTP request to your Dioxus app is now instrumented.
Finally, tokio::try_join! runs both servers concurrently. If either one fails, the whole future fails. This is important — you don’t want the app running without observability, nor the metrics server dangling alone if the app crashes.
Two routers
It’s worth pausing here to appreciate the design. We have two completely separate Router instances:
- Application router — serves the Dioxus fullstack app on port 8080, with the Prometheus layer attached as middleware
- Metrics router — a minimal Axum router on port 9090, exposing only
/metrics
Why keep them separate? Because the metrics endpoint doesn’t need to go through all the application middleware (authentication layers, CORS, etc. that you might add later). And more importantly, it binds to a different port entirely. Fly.io’s public-facing HTTP proxy only routes to port 8080. Port 9090 is only accessible internally within the Fly.io private network — where their Prometheus scraper lives.
This is the security trick. No firewall rules to write. No auth tokens on /metrics. Just topology.
Dockerfile
The Docker setup uses a multi-stage build with cargo-chef for layer caching — a huge win for CI build times since Rust dependencies don’t recompile unless Cargo.lock changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
FROM rust:1 AS chef
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
# Install dx CLI
RUN curl -L --proto '=https' --tlsv1.2 -sSf \
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall dioxus-cli --root /.cargo -y --force
ENV PATH="/.cargo/bin:$PATH"
# Bundle the web release
RUN dx bundle --web --release
FROM chef AS runtime
COPY --from=builder /app/target/dx/hot_dog/release/web/ /usr/local/app
ENV PORT=8080
ENV IP=0.0.0.0
EXPOSE 8080
EXPOSE 9090
WORKDIR /usr/local/app
ENTRYPOINT [ "/usr/local/app/hot_dog" ]
A few things to highlight:
- The
plannerstage generates arecipe.jsonthat describes dependencies without building them. Thebuilderstage uses this tocargo chef cook— compiling only deps first, cached as a Docker layer. Your actual source code change only invalidates the last step. dx bundle --web --releaseis the Dioxus CLI command that compiles the server binary (--features serveris implied by fullstack), the WASM client, and bundles static assets together.- The runtime stage copies the bundle from
/app/target/dx/hot_dog/release/web/and setsIP=0.0.0.0so the server binds to all interfaces (necessary inside a container). - Both
EXPOSE 8080andEXPOSE 9090are declared. This is documentation-level — DockerEXPOSEdoesn’t actually publish ports, but it signals intent and is used by orchestrators like Fly.io to understand what the container offers.
Fly.io configuration
Now the part that ties it all together. The fly.toml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
app = 'hot-dog-still-tree-2047'
primary_region = 'fra'
[env]
RUST_LOG = "info"
[build]
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
memory_mb = 1024
[metrics]
port = 9090
path = "/metrics"
processes = ["app"]
The [http_service] block tells Fly.io’s edge proxy to route public HTTPS traffic to internal port 8080. That’s your Dioxus app. force_https and the auto stop/start policies are quality-of-life for a low-traffic personal app — machines spin down when idle and back up on the first request.
The [metrics] block is where the magic happens for observability. Fly.io’s internal Prometheus scraper will call GET /metrics on port 9090 of each running machine. Since this port is never registered in [http_service], it is not reachable from the public internet — only accessible from within Fly.io’s private network. Your metrics are safe from curious fingers and potential abuse.
You can then view the scraped metrics in Fly.io’s built-in Grafana dashboards, or even connect your own Prometheus instance via the Fly.io metrics federation endpoint.
Conclusion
What I like about this setup is how naturally the security model falls out of the architecture. You don’t need to put a reverse proxy in front of your metrics, you don’t need to add HTTP basic auth to /metrics, and you don’t need firewall rules. The separation of concerns — two routers, two listeners, two ports — maps directly onto the Fly.io network model where only one of those ports is publicly exposed.
tokio::try_join! deserves a mention too. It’s the kind of primitive that makes async Rust feel elegant: run both servers concurrently, treat them as a unit, fail fast if either goes down. No daemon management, no supervisord, just the type system and the runtime working together.
Dioxus fullstack is still maturing, and I don’t recommend it for production if you need battle-tested stability. But for a personal project where you want SSR + WASM + server functions in one Rust codebase, it’s a genuinely exciting stack. And as this post shows, since it’s Axum under the hood, you can reach in and do things like plug in observability layers without fighting the framework.
Give hot_dog a look if you want to see all of this in context. Feedback and PRs welcome.
This post was entirely written by AI.