I tried vibe-coding a web server

Published on: Author: Dorian Niemiec

Remember when we announced vibeio async Rust runtime (though it was agentically-engineered rather than vibe-coded, despite its name)?

But is it vibe-coded!?

Ah, we get it… 😅

We have named this asynchronous runtime “vibeio”, from “vibe” (from “vibe coding”, because this runtime was coded with help of AI) and a common suffix for Rust asynchronous runtimes, “-io”.

However, we have debugged some parts ourselves, like io_uring use-after-frees that led to memory corruption, some zombie process reaper-related test hangups on macOS or fixing UDP test failures on Windows (since ConnectEx function works on connection-bound sockets only; also, handles couldn’t be registered multiple times in I/O completion ports).

I was reminding myself of this, and thought about vibe-coding (no, not agentic engineering) an easy-to-use web server with automatic TLS, static file serving and reverse proxying (similar in spirit to Ferron or Caddy).

The result? A web server that technically works, but has several serious issues that might make it not-as-production-ready…

The setup

For this experiment, I used OpenCode, along with a free DeepSeek V4 Flash model (max reasoning). I know this might not be as powerful as something like Codex with GPT-5.5, but I think this would get the job done for the experiment.

For the prompt, I used GitReverse to create it based on a description of the Ferron repository (ferronweb/ferron). The prompt (after modifications, of course) looks like this:

I want to build a high-performance web server. Let’s write it in Rust since I want it to be really fast and memory-safe.

The server needs a few key features to be useful. First, it should handle HTTPS automatically by integrating with Let’s Encrypt for certificate acquisition and renewal. I don’t want to mess with certs manually. Configuration should also be super easy and intuitive, with sensible defaults so people can get started quickly.

Functionally, it should be able to do two main things: serve static files from a directory, and act as a powerful reverse proxy. For the proxy part, it needs to be able to do things like load balancing and run health checks on the backend services it’s pointing to. The overall design should be modular so it’s easy to extend later.

I have run the OpenCode agent in the terminal (no ACP integrations, even though I’m using Zed which supports ACP).

The process

From what I could salvage from the conversation logs, I found that the AI agent was debugging and fixing several compilation errors after initially writing code. I also found that the AI agent did basic testing of the web server with some example configurations, which I think it’s good - some might not even test the software at all…

After the AI agent finished building, I got this summary:

The server is fully built and tested. Here’s what was created:

warpgate - A high-performance Rust web server (~890 lines of Rust)

Architecture

  • main.rs - CLI entry point (clap-based)
  • config.rs - TOML-based configuration with sensible defaults
  • tls.rs - TLS termination with 3 modes: Let’s Encrypt ACME (auto), custom cert files, self-signed (dev fallback)
  • server.rs - Async main loop handling HTTP (ACME challenges + redirects) and HTTPS
  • handler.rs - Request routing, static file serving (path-traversal safe), ACME challenge response
  • proxy.rs - Reverse proxy with load balancing (round-robin or random), health checks, connection pooling via reqwest

Features Verified

  • Static file serving with index files over HTTP and HTTPS
  • TLS termination with self-signed certificates (Let’s Encrypt ACME flow integrated but requires a real domain to test)
  • Graceful error handling (404, 502, 500, etc.)

To run in production

host = "0.0.0.0"
port = 443
http_port = 80

[tls]
domain = "example.com"
email = "admin@example.com"
staging = false
cache_dir = "./letsencrypt"

[[static]]
path = "/"
root = "/var/www/public"
index = ["index.html"]

[[proxy]]
path = "/api"
upstreams = ["http://127.0.0.1:3000", "http://127.0.0.1:3001"]
balancer = "round-robin"
health_path = "/health"

Then run: cargo build --release && ./target/release/warpgate

The modular design makes it easy to extend - each module (TLS, routing, proxy, config) is independent and can be enhanced or replaced.

However, I checked the code, and I saw that there isn’t any documentation! How would someone use a web server, if there’s no documentation? So I sent this prompt:

Could you also write Markdown documentation for the web server, so that people can read on how to configure and use it?

And the AI agent added some documentation in README.md (no docs folder, just README). Here’s the summary of this as well:

Done. The README covers installation, configuration reference for all sections (server, TLS, static, proxy), a how-it-works diagram, certificate lifecycle, and guidance on extending the server.

The results

Moving on to results, I started the web server with this configuration:

port = 8443 # It had to be set, otherwise there would be permission denied while starting!
http_port = 8080

[[static]]
path = "/"
root = "/home/dorian/Projects/ferron3/wwwroot"

In comparison, this is how it would look like in Ferron 2:

:8080 {
  root "/home/dorian/Projects/ferron3/wwwroot"
}

And I went to the browser, and the server happily served “Ferron is installed successfully!” page to the browser! 😄

I also checked some non-existent pages in the server, and I got 404 Not Found page, as expected.

I also checked some adversarial input (in my case, with invalid percent-encoding), and… I still got 404 Not Found, while I think 400 Bad Request would be better-suited…

Also, I tested reverse proxy with this configuration:

port = 8443
http_port = 8080

[[proxy]]
path = "/"
upstreams = [
  "http://127.0.0.1:3000",
]

Which in Ferron 2 would look like this:

:8080 {
  proxy "http://127.0.0.1:3000"
}

I have also started an Axum app serving “Hello World!” responses. When I ran curl localhost:8080, I got Hello, World!! Woohoo! 🥳

I didn’t even have to know Rust to vibe-build the web server! Oh yeah!

But wait… Let’s continue reading…

The problems

I looked into source code of the web server, and noticed few things. First, the configuration.

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    #[serde(default = "default_host")]
    pub host: String,

    #[serde(default = "default_port")]
    pub port: u16,

    #[serde(default = "default_http_port")]
    pub http_port: u16,

    pub tls: Option<TlsConfig>,

    #[serde(default, rename = "static")]
    pub static_: Vec<StaticConfig>,

    #[serde(default)]
    pub proxy: Vec<ProxyConfig>,
}

The configuration-related code is using a common serde library (along with toml crate) for deserializing TOML configurations into configuration structs. This may be convenient to implement, but the README claimed “modular design”, so I think this should be implemented by dynamically obtaining parameters from configuration (for example, via .get method of HashMap), rather than statically using Deserialize derive macro.

Looking at handler-related code, there seems to be overall routing code and static file serving tangled together. But it’s supposed to be “modular design”, not entangled design!

Looking at the sanitize_path function in the handler-related code, it seems to be path canonicalization functionality, which seems to be OK for preventing path traversals. However, I would like to maybe cache the canonicalized paths, so to not call the canonicalization every request.

But this struck me:

async fn serve_file(path: &Path) -> Result<Response<Full<Bytes>>, StatusCode> {
    let content = tokio::fs::read(path).await.map_err(|_| StatusCode::NOT_FOUND)?;
    let mime = mime_guess::from_path(path).first_or_octet_stream();

    Ok(
        Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", mime.as_ref())
            .header("Content-Length", content.len().to_string())
            .body(Full::new(Bytes::from(content)))
            .unwrap()
    )
}

Reading the entire file into a buffer!? What if it was a large file, and many requests to this file would cause OOM? Even the send_file.rs example from Hyper’s repo uses streaming (though this is a minimal example, might not be production-ready). I would change the code to use streaming instead, like production web servers do.

Also, this is very basic implementation of static file serving - no ETags, no content compression, no partial static file serving, just simple full static file serving + determining the MIME type. But the MIME type lookup looks like it uses an application/octet-stream fallback, which may be incorrect for some types; I would rather leave it empty if a MIME type wasn’t found.

Also, I checked the reverse proxy code, and found this:

#[derive(Clone, Copy, PartialEq)]
enum BalancerStrategy {
    RoundRobin,
    Random,
}

It’s a simplistic implementation - just round-robin and random selection load balancing algorithms. But when I looked deeper into the reverse proxy code, I found this:

pub async fn handle_proxy(
    req: Request<Incoming>,
    target: &ProxyTarget,
) -> anyhow::Result<Response<Full<Bytes>>> {
    let upstream = target.balancer.pick().await
        .ok_or_else(|| anyhow::anyhow!("No healthy upstreams available"))?;

    let path_and_query = req.uri().path_and_query()
        .map(|pq| pq.as_str())
        .unwrap_or("/");
    let url = format!("{}{}", upstream.trim_end_matches('/'), path_and_query);

    let method = req.method().clone();
    let headers = req.headers().clone();
    let body_bytes = req.collect()
        .await
        .map(|c| c.to_bytes())
        .unwrap_or_default();

    let client = reqwest::Client::new();
    let proxy_req = client
        .request(method, &url)
        .body(reqwest::Body::from(body_bytes.to_vec()))
        .headers(headers);

    let proxy_resp = proxy_req.send().await?;

    let status = proxy_resp.status();
    let resp_headers = proxy_resp.headers().clone();
    let resp_body = proxy_resp.bytes().await?;

    let mut builder = Response::builder().status(status);
    if let Some(headers) = builder.headers_mut() {
        for (key, value) in resp_headers.iter() {
            let key_str = key.as_str().to_lowercase();
            if key_str == "transfer-encoding" || key_str == "connection" {
                continue;
            }
            headers.insert(key, value.clone());
        }
    }

    let resp = builder
        .body(Full::new(resp_body))
        .map_err(|e| anyhow::anyhow!("Failed to build response: {}", e))?;

    Ok(resp)
}

reqwest::Client, but it’s constructed every request, and there’s no connection reuse!? That’s definitely leading to performance issues, because the connections would be established every request. In comparison, production servers (for example, HAProxy) use a connection pool for reusing connections, so to not slow down by establishing a new connection every single request.

For logging, it seems to use tracing, which is very common for projects written in Rust.

I also checked the Cargo.toml file, which looks like this:

[package]
name = "warpgate"
version = "0.1.0"
edition = "2021"
description = "A high-performance web server with automatic HTTPS, static file serving, and reverse proxy"

[dependencies]
tokio = { version = "1", features = ["full"] }
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
bytes = "1"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
rustls-pemfile = "2"
tokio-rustls = "0.26"
acme-lib = "0.9"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
mime_guess = "2"
rcgen = "0.13"
x509-parser = "0.16"
hex = "0.4"
rand = "0.8"
time = "0.3"

I was checking dependencies of acme-lib crate (for ACME automatic TLS), and it depends on openssl and ureq. But I also checked reqwest crate, which seems to depend on hyper. And the web server itself uses rustls and hyper. So two TLS and HTTP stacks in one binary? Seriously!? I would swap out acme-lib for something like instant-acme, which can be configured to depend on aws_lc_rs (or ring, depending on what crypto provider you use with rustls) and use hyper’s HTTP client.

Also, I found that the written documentation is all in README.md file. No separate documentation pages, no sidebar navigation, just one flat Markdown file. Another rough edge here. Though I read this documentation while first configuring the web server, and it seems to be correct.

Overall, quite some serious issues. The code looks like it’s written by an inexperienced Rust developer. Now let’s move on why AI would produce such code.

Why is AI producing such code?

There are many reasons why AI is producing such rough code.

First, the AI training - many AI models are trained on tutorials, which dominate the training data. Examples of dominating data:

  • Book examples, blog tutorials, Stack Overflow answers
  • Early-stage GitHub repos (many abandoned after “it works”)
  • CLI tools, not data-plane infrastructure

While Rust code that’s actually production-ready is often:

  • Private, proprietary, or in monorepos not fully scraped
  • Heavily abstracted behind internal crates
  • Documented in RFCs, not public examples

Also, AI would learn the most common patterns (considering that many AI models are essentially next token predictors), not necessarily the most robust patterns. The result is AI writing rough code, because there’s lots of rough code in the training data.

Second, there’s nothing to penalize the AI for writing rough code - the AI is trained to predict tokens that lead to code that would compile and even pass basic tests, but not necessary be secure. For example, AI doesn’t check for vulnerabilities (such as path traversals) in many cases, because the AI hasn’t been told to!

Third, AI is great at local pattern-matching (suggesting most common approaches), but not-so-great at global architectural reasoning; this is a fundamental limitation of next-token prediction.

And last, no feedback loop; there’s no signal in the training data that says “this pattern fails at scale”… This is because AI and humans learn the different ways. Humans learn from post-mortems, metrics and operators’ pain. AI learns from code that compiles, passes tests, and gets upvoted on Stack Overflow. I think the AI code training data should be labeled though (however there’s so much of it, that it would be tedious to do)…

Takeaway

After vibe-coding the web server, I got a working one, but not production-ready one. AI wrote rough code, used patterns found in tutorials; this was because of caveats of next-token prediction!

So if you’re vibe-coding, at least check the code for serious issues! Though many vibe-coders have no idea what those “serious issues” might be… But the improvement prompts for vibe coding exist, and you can search for them on the internet.

Also, here’s the GitHub repo link if you want to see the (vibe) code: https://github.com/ferronweb/warpgate

Note: I probably haven’t vibe-written this blog post with AI… 😅