Building Wasm Microservices with Spin, From Hello World to Production
TL;DR — Spin 3.1 is Rails for Wasm microservices.
spin new, write a handler,spin upfor local dev,spin deployto Fermyon Cloud or self-host. WASI 0.2 components under the hood with batteries for HTTP routing, key-value, SQLite, and secrets.
The hardest sell for Wasm microservices used to be the operational gap. You had a runtime, you had a language SDK, and between them you had a thousand lines of boilerplate to glue HTTP to a component. Spin closed that gap. It’s the first Wasm framework where I can hand a backend engineer a brief and they ship something the same afternoon.
This tutorial walks the full lifecycle. A real HTTP service. Persistence. Configuration. Deployment. We’re not building a hello-world and stopping. We’re building something you could put behind a load balancer. If you want background on the broader runtime story, the server-side Wasm survey sets context.
A note before we start. Spin abstracts wasmtime, which means you get its safety properties (sandboxing, memory caps) without writing the embedding code from the wasmtime embedding tutorial. That’s the trade. Less control, faster iteration.
Setup
Step 1, Install Spin 3.1
# macOS / Linux
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
sudo mv ./spin /usr/local/bin/
spin --version
# spin 3.1.0
Step 2, Add the Rust SDK Prereqs
rustup install 1.84.0
rustup default 1.84.0
rustup target add wasm32-wasip2
cargo install cargo-component --version 0.18.0 --locked
Spin’s Rust template uses the SDK behind the scenes, but cargo-component is what actually builds the components. Skip it and spin build will tell you why.
Step 3, Install Spin Templates
spin templates install --git https://github.com/spinframework/spin --upgrade
spin templates list
You should see http-rust, http-js, http-py, and friends.
Scaffold the Service
We’ll build links, a tiny URL shortener. POST a URL, get a short slug back. GET the slug, redirect. Persistence via Spin’s key-value store. The shape is small but it touches everything.
+---------------------+
| spin.toml |
| - components[] |
| - triggers[] |
+----------+----------+
|
+-------v-------+ POST /shorten +----------------+
| trigger +------------------>| KV store |
| (HTTP) | | (Spin runtime)|
| | GET /:slug | |
| +------------------>| |
+-------+-------+ +----------------+
|
+-------v-------+
| component |
| links.wasm |
+---------------+
Step 1, Create the Project
spin new -t http-rust links
cd links
# answer prompts: HTTP path "/..." for catch-all
The generated layout:
links/
spin.toml
Cargo.toml
src/lib.rs
.cargo/config.toml
spin.toml is the manifest. src/lib.rs is the handler. That’s it.
Step 2, Set Up Dependencies
Cargo.toml:
[package]
name = "links"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
spin-sdk = "3.1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"] }
Step 3, Configure the Manifest
spin.toml:
spin_manifest_version = 2
[application]
name = "links"
version = "0.1.0"
authors = ["Muhammad Amal"]
description = "URL shortener"
[[trigger.http]]
route = "/..."
component = "links"
[component.links]
source = "target/wasm32-wasip2/release/links.wasm"
allowed_outbound_hosts = []
key_value_stores = ["default"]
[component.links.build]
command = "cargo component build --release"
watch = ["src/**/*.rs", "Cargo.toml"]
The key_value_stores = ["default"] line is the capability grant. Without it, the component cannot access KV at all. Capability-based security, on by default.
Write the Handler
src/lib.rs:
use spin_sdk::http::{IntoResponse, Method, Params, Request, Response, Router};
use spin_sdk::http_component;
use spin_sdk::key_value::Store;
use serde::{Deserialize, Serialize};
use rand::Rng;
#[derive(Deserialize)]
struct ShortenRequest {
url: String,
}
#[derive(Serialize)]
struct ShortenResponse {
slug: String,
short_url: String,
}
#[http_component]
fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
let mut router = Router::default();
router.post("/shorten", shorten);
router.get("/health", health);
router.get("/:slug", redirect);
Ok(router.handle(req))
}
fn health(_req: Request, _params: Params) -> anyhow::Result<Response> {
Ok(Response::new(200, "ok"))
}
fn shorten(req: Request, _params: Params) -> anyhow::Result<Response> {
let body: ShortenRequest = serde_json::from_slice(req.body())?;
if !body.url.starts_with("http") {
return Ok(Response::new(400, "url must be http(s)"));
}
let slug = random_slug(6);
let store = Store::open_default()?;
store.set(&slug, body.url.as_bytes())?;
let host = req
.header("host")
.and_then(|h| h.as_str())
.unwrap_or("localhost");
let resp = ShortenResponse {
slug: slug.clone(),
short_url: format!("http://{host}/{slug}"),
};
Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.body(serde_json::to_vec(&resp)?)
.build())
}
fn redirect(_req: Request, params: Params) -> anyhow::Result<Response> {
let slug = params.get("slug").unwrap_or("");
let store = Store::open_default()?;
match store.get(slug)? {
Some(bytes) => {
let url = String::from_utf8(bytes)?;
Ok(Response::builder()
.status(302)
.header("location", url)
.body(())
.build())
}
None => Ok(Response::new(404, "not found")),
}
}
fn random_slug(len: usize) -> String {
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..len)
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
.collect()
}
A few things worth pointing at. #[http_component] is the entry-point macro. Spin’s Router is path+method dispatch with named params. Store::open_default() opens the KV store you authorized in spin.toml. The handler returns Response. No async needed for this version of the SDK in this shape.
Step 4, Build and Run
spin build
spin up
# Logging component stdio to ".spin/logs/"
# Storing default key-value data to ".spin/default_key_value.db"
# Serving http://127.0.0.1:3000
In another terminal:
curl -X POST http://127.0.0.1:3000/shorten \
-H 'content-type: application/json' \
-d '{"url": "https://example.com/long/path?with=query"}'
# {"slug":"a3b9z2","short_url":"http://127.0.0.1:3000/a3b9z2"}
curl -i http://127.0.0.1:3000/a3b9z2
# HTTP/1.1 302 Found
# location: https://example.com/long/path?with=query
That’s a working microservice with persistence in roughly 80 lines.
Production Concerns
The hello-world part is done. Now the part that decides whether you sleep at night.
Configuration and Secrets
Spin variables are the configuration mechanism. Declare in spin.toml:
[variables]
admin_token = { required = true }
allowed_origin = { default = "https://example.com" }
[component.links]
# ...
[component.links.variables]
admin_token = "{{ admin_token }}"
allowed_origin = "{{ allowed_origin }}"
In code:
use spin_sdk::variables;
let token = variables::get("admin_token")?;
let origin = variables::get("allowed_origin")?;
Set them at runtime:
SPIN_VARIABLE_ADMIN_TOKEN=s3cret spin up
For production on Fermyon Cloud, use spin cloud variables set admin_token=.... Self-hosted deployments can source them from environment variables or a Vault provider.
SQLite When KV Isn’t Enough
Spin ships a SQLite component as well. Add to spin.toml:
[component.links]
sqlite_databases = ["default"]
use spin_sdk::sqlite::{Connection, Value};
let conn = Connection::open_default()?;
conn.execute(
"CREATE TABLE IF NOT EXISTS clicks (slug TEXT, ts INTEGER)",
&[],
)?;
conn.execute(
"INSERT INTO clicks VALUES (?, ?)",
&[Value::Text(slug.into()), Value::Integer(now)],
)?;
The local file lives under .spin/. In Fermyon Cloud it’s backed by managed Turso (libSQL).
Outbound HTTP
If your component needs to call other services, declare allowed hosts. This is a capability grant.
[component.links]
allowed_outbound_hosts = ["https://api.example.com"]
Without that, spin_sdk::http::send returns a permission error. By design.
Deployment
Fermyon Cloud is the easiest target.
spin cloud login
spin deploy
# Deploying...
# Waiting for application to become ready... ready
# Available Routes:
# links: https://links-abcdef.fermyon.app (wildcard)
For self-hosted, the typical pattern is Spin running as a systemd service behind a reverse proxy. The Spin docs at spinframework.dev cover the Kubernetes operator if you need fleet management. Or skip Spin entirely and run on wasmCloud, which I’ll cover in a later article.
Composing Multiple Spin Components
Real services aren’t single-component. Spin supports multi-component applications cleanly, where each component handles a different route or trigger and components can call each other. The pattern is to declare multiple components in spin.toml, each with its own route prefix, and let Spin’s router dispatch.
[[trigger.http]]
route = "/api/links/..."
component = "links"
[[trigger.http]]
route = "/api/users/..."
component = "users"
[[trigger.http]]
route = "/internal/admin/..."
component = "admin"
Each component lives in its own crate under a workspace. They share types via a common crate that compiles to host code (used for tests) but each ships its own component artifact. Spin’s local KV is shared across components if they list the same store. Capabilities are still granted per component, so the admin component can have outbound HTTP to your SSO provider while links cannot.
For service-to-service calls within the same Spin app, the idiomatic move is to mount internal-only routes (e.g., under /internal/) and have components call each other via spin_sdk::http::send with an internal URL. Spin handles the call locally without going through the public listener. It’s not zero-cost (still a request round-trip in user space), but it’s fast and keeps the component boundary clean.
Testing
Spin supports component-level testing via spin-test for the WIT contracts, plus regular HTTP integration tests.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_length() {
let s = random_slug(6);
assert_eq!(s.len(), 6);
assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
}
}
Run with cargo test against the host target, not wasm32-wasip2. For end-to-end, spin up in a CI job and curl the routes. Spin’s local KV is ephemeral by default, which is what you want for tests.
Common Pitfalls
Missing capability grants in spin.toml. If your handler errors with capability not granted or kv: store not authorized, you forgot the key_value_stores, sqlite_databases, or allowed_outbound_hosts entry. The component cannot access anything the manifest doesn’t list.
Hot reload not picking up changes. spin watch (and spin up --build) watches the paths in [component.X.build].watch. If you added a new directory and forgot to update the glob, builds happen but the runtime keeps the old artifact. Update watch and restart.
Body-too-large errors on POST. Spin’s default max body is 5 MB. For larger uploads set [component.X] max_request_size = 50_000_000 (bytes). Don’t crank this without thinking. Wasm guests run under a memory cap.
Logging silently swallowed. Spin captures stdout to .spin/logs/. If your println! doesn’t show up in the terminal, check the log directory. For structured logging, use tracing and configure a writer that calls eprintln!, which Spin surfaces to its own log.
Troubleshooting
spin build fails with cargo-component not found. Install it as in the setup step. Spin shells out to cargo component build --release, it doesn’t bundle the toolchain.
**spin up reports “component not found”.** Run spin buildfirst, or set[component.X.build].commandto match your build setup. Spin doesn't auto-build onupunless you pass–build`.
Deployment to Fermyon Cloud fails with size limit. Free tier has artifact-size caps. Run wasm-opt -Os post-build, strip debug info, and check du -sh target/wasm32-wasip2/release/*.wasm. The component model docs at component-model.bytecodealliance.org cover size optimization in more depth.
Self-Hosting Spin
Fermyon Cloud is fine for small projects, but most teams I work with want self-hosted control. Spin can run anywhere as a long-running process; the only real choice is how you supervise it. For single-host: systemd unit pointing at spin up --listen 0.0.0.0:80 --runtime-config-file runtime.toml. For Kubernetes: the Spin operator handles deployment of Spin applications as Kubernetes resources. For “just a binary on a VM”: package the spin binary plus your .wasm artifacts in a tarball, drop on the box, run.
The runtime.toml is where you bind external services. KV against Redis, SQLite against Turso, secrets against Vault. The application’s spin.toml declares what it needs at the abstraction layer; runtime.toml decides which provider implements each abstraction in this environment. Same artifact runs against local SQLite in dev and managed Postgres in production with no code change.
For observability, Spin emits OTel traces if you pass --otlp-endpoint. Metrics export is via Prometheus on /metrics if you enable the --prometheus-listen-address flag. Standard practice is to scrape that endpoint into your existing Prometheus and pipe traces to your existing collector. There’s nothing exotic; the operational surface is similar to any other long-running HTTP service.
For zero-downtime deploys, run Spin behind a load balancer with multiple instances, drain one at a time on new artifact rollout. Spin’s startup time is sub-second for typical apps, so the drain windows are short. No magic.
Wrapping Up
You now have a Spin microservice with HTTP routing, persistence, configuration, and a deployment story. Spin is the right answer when you want the productivity of a framework and you don’t need to customize the host. When you do need to customize, drop down to wasmtime and re-embed manually. The next article in the series will compare Wasm to Docker head to head so you have a clear picture of where each one earns its place.