Skip to main content
Version: v2

Building Custom Hosts with wasmCloud

Build custom wasmCloud hosts for specialized deployment scenarios.​

The wash-runtime crate provides a Rust library for embedding wasmCloud host functionality in your own applications. This enables you to create custom hosts tailored to specific requirements—whether for edge deployments, specialized hardware, or integration with existing systems.

When to build a custom host​

Custom hosts are useful when you need to:

  • Embed WebAssembly execution in an existing Rust application
  • Deploy to constrained environments where the full wasmCloud stack is too heavy
  • Integrate with proprietary systems that require custom plugins or configurations
  • Build specialized tooling around WebAssembly workloads

For most production deployments, the standard wasmCloud host managed by the Kubernetes operator is recommended—this runs as a cluster host (washlet) that receives workload commands over NATS. Build a custom host only when you have specific requirements that can't be met by the standard deployment model.

Prerequisites​

Add wash-runtime to your Cargo.toml:

toml
[dependencies]
wash-runtime = "*"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
tracing-subscriber = "0.3"

Enable only the features you need to minimize binary size:

toml
[dependencies]
wash-runtime = { version = "*", default-features = false, features = [
    "wasi-keyvalue",
    "wasi-config",
] }

Available features​

FeatureDefaultDescription
wasi-configYesRuntime configuration interface
wasi-loggingYesLogging interface
wasi-blobstoreYesBlob storage interface
wasi-keyvalueYesKey-value storage interface
wasmcloud-postgresYesDirect PostgreSQL access (queries and prepared statements)
wasmcloud-natsYes (since 2.9.0)NATS-native core, JetStream, and key-value access
wasm_component_model_implementsYesMulti-backend binding via the implements clause
wasi-otelYesOpenTelemetry traces, metrics, and logs export
washletYesWashlet support (depends on oci)
wasi-webgpuYesWebGPU interface
ociYes (via washlet)OCI registry integration for pulling components
wasi-tlsNoTLS termination for wasi:tls components
host-component-pluginsNoHost component plugin support (included in the published -all-features image since 2.10.0)

Architecture overview​

A typical custom host architecture includes:

  1. Engine: Configures and manages the underlying Wasmtime runtime
  2. Plugins: The host is built with plugins to enable capabilities
  3. Workloads: Units of execution containing components and optional services
┌─────────────────────────────────────────────────┐
│                  Custom Host                    │
│  ┌───────────┐  ┌───────────┐  ┌───────────┐    │
│  │  Engine   │  │  Plugins  │  │ Workloads │    │
│  │           │  │           │  │           │    │
│  │ Wasmtime  │  │ HTTP      │  │ Component │    │
│  │ Config    │  │ KeyValue  │  │ Component │    │
│  │ Pooling   │  │ Config    │  │ Service   │    │
│  └───────────┘  └───────────┘  └───────────┘    │
└─────────────────────────────────────────────────┘

Creating an Engine​

The Engine wraps Wasmtime and handles WebAssembly compilation. Use Engine::builder() to configure it:

rust
use wash_runtime::engine::Engine;

// Default configuration
let engine = Engine::builder().build()?;

// With pooling allocator enabled
let engine = Engine::builder()
    .with_pooling_allocator(true)
    .build()?;

// With a fully custom wasmtime configuration
// (wasmtime is re-exported by wash-runtime)
let mut config = wash_runtime::wasmtime::Config::new();
config.cranelift_opt_level(wash_runtime::wasmtime::OptLevel::Speed);
let engine = Engine::builder()
    .with_config(config)
    .build()?;

Engine configuration options​

MethodDescription
with_pooling_allocator(bool)Enable/disable instance pooling for better performance with many short-lived instances
with_config(wasmtime::Config)Provide a fully custom Wasmtime configuration for advanced use cases
with_host_memory(HostMemoryBudgets)Set the guest memory budget, per-memory heap ceiling, and pool instance slots (since 2.9.0). HostMemoryBudgets::resolve_strs and parse_bytes turn textual config (Kubernetes quantity grammar) into budgets
with_guest_memory_mode(GuestMemoryMode)Enforce (the default since 2.11.0) refuses guest memory growth past the budget; Count records what enforcement would refuse (since 2.9.0)
without_max_process_memory()Turn off the host-process memory limit, which is on by default for every embedder since 2.11.0. with_max_process_memory_bytes sets a limit of your own instead of the derived one
with_fuel_consumption(bool)Compile fuel counters into guest code, required for --meters fuel-style exact execution metering (predates 2.9.0)
Pooling allocator

The pooling allocator is automatically enabled on machines with sufficient virtual memory. You can override this behavior with the with_pooling_allocator() method.

TLS for wasi:tls components​

wash-runtime 2.2 added support for the WASI Preview 3 wasi:tls interfaces, letting components terminate TLS connections themselves over wasi:sockets. This is distinct from host-side HTTPS termination — wasi:tls is the option to reach for when a component needs to own the TLS session (for example, to connect to a third-party service or a database that requires TLS).

To enable it, build wash-runtime with the wasi-tls Cargo feature:

toml
[dependencies]
wash-runtime = { version = "2.2", features = ["wasi-tls"] }

The feature is opt-in while the underlying WIT interfaces (wasi:tls/client and wasi:tls/types@0.3.0-draft) stabilize. When enabled, the engine wires wasmtime-wasi-tls into the linker. wasmCloud standardizes on rustls backed by aws-lc-rs, not the upstream ring default: wash-runtime's init_crypto() installs aws-lc-rs as the process-level rustls provider at startup, and the workspace rustls pin compiles ring out of the dependency tree entirely.

To override the provider — to install a custom root store, pin a specific certificate, or bridge to an HSM-backed key — register a SharedTlsProvider on the engine builder:

rust
use wash_runtime::engine::Engine;

let engine = Engine::builder()
    .with_tls_provider(my_custom_provider)
    .build()?;

If the wasi-tls feature is enabled but no provider is set, the runtime falls back to the wasmtime-wasi-tls default and logs a warning recommending EngineBuilder::with_tls_provider.

Building the Host​

Use HostBuilder to construct a host with your desired configuration. All capabilities (HTTP, key-value, configuration, etc.) are provided as plugins:

rust
use std::sync::Arc;
use wash_runtime::{
    engine::Engine,
    host::{HostBuilder, HostApi, http::{Ingress, DevRouter}},
    plugin::wasi_config::DynamicConfig,
};

let engine = Engine::builder().build()?;

// Configure HTTP handler and plugins
let http_handler = Ingress::new(DevRouter::default(), "127.0.0.1:8080".parse()?).await?;
// For HTTPS, use Ingress::new_with_tls() with a TlsConfig
let config_plugin = DynamicConfig::new(false);

// Build the host
let host = HostBuilder::new()
    .with_engine(engine)
    .with_hostname("my-custom-host")
    .with_friendly_name("My Custom Host")
    .with_label("environment", "production")
    .with_label("region", "us-west-2")
    .with_http_handler(Arc::new(http_handler))
    .with_plugin(Arc::new(config_plugin))?
    .build()?;

// Start the host (initializes all plugins)
let host = host.start().await?;

HostBuilder methods​

MethodDescription
with_engine(Engine)Set the WebAssembly engine (creates default if not set)
with_hostname(str)Set the system hostname (defaults to OS hostname)
with_friendly_name(str)Set a human-readable name (auto-generated if not set)
with_label(key, value)Add metadata labels for identification
with_http_handler(Arc<dyn HostHandler>)Register the HTTP handler (Ingress implements HostHandler, not HostPlugin)
with_plugin(Arc<dyn HostPlugin>)Register a plugin (can be called multiple times; IDs must be unique)
with_plugin_bindings(PluginBindings)Install operator-declared plugin bindings; validated against each plugin's schema at build(), so a bad declaration fails startup (since 2.9.0)
with_meters(Meters)Choose guest execution metering. A host built without it gets duration metering by default (since 2.9.0); build() warns when fuel meters and the engine's fuel setting disagree

Embedder defaults at 2.8.0​

Two embedder-facing changes in 2.8.0:

  • Socket policy defaults now match the CLI. A host built without an explicit socket policy previously ran with egress address screening off. As of 2.8.0, the default denies special address ranges, allows private ranges, uses count egress mode, and keeps host loopback off, matching the defaults wash applies. Embedders that relied on the permissive default should set a policy explicitly.
  • OCI CA trust is settable via HostConfig. The new oci_ca_paths field (default empty) adds CA certificates to trust when pulling from OCI registries, matching the CLI's --oci-ca-path.

Embedder changes at 2.9.0​

  • Metering is on by default. A host built without with_meters measures invocation counts, failures, and durations (guest.invocation.duration). Fuel metering requires both with_meters(Meters::new(MeterKind::Fuel)) and EngineBuilder::with_fuel_consumption(true).
  • Breaking signatures: UnresolvedWorkload::resolve gained two parameters, plugin_bindings: &PluginBindings (inserted before http_handler) and meters: &Meters (last); HostHandler::deliver_trigger_service_message gained an attributes: Arc<[KeyValue]> parameter; Meters::new takes a MeterKind instead of a bool.
  • Plugin dispatch is public API: engine::dispatch lets a native plugin push calls into workload guest code through ResolvedWorkload::dispatch_target (see Creating host plugins).
  • Descriptor limits: wash host raises the process's soft file-descriptor limit toward the hard limit at startup; library embedders should call raise_descriptor_limit() themselves if they serve ingress traffic.

Embedder changes at 2.10.0​

  • Wasmtime 48. OutgoingHandler::send_request, HostHandler::outgoing_request, and Router::allow_outgoing_request are unified over WasiBody/RequestOptions; the host::http_p3 types (P3Body, P3SendFuture, and friends) are removed; wasmtime_wasi::FsPerms replaces DirPerms/FilePerms in volume wiring. MSRV is 1.95.
  • Observability: observability::install_providers() returns a Providers handle (logs_layer, tracing_layer, meter_provider) for embedders that install their own subscriber; export activation is keyed on OTEL_* endpoint variables.
  • Engine: with_native_unwind_info(bool) (or WASMTIME_NATIVE_UNWIND_INFO) can turn off per-function unwind registration, which makes dropping components on busy hosts far cheaper under LLVM libunwind (macOS and musl builds); native DWARF profilers lose guest frames.
  • Egress identity: ClientTlsOptions::with_client_identity presents a client certificate; ClientTlsConfigResolver and WorkloadClients::with_tls_config_resolver select per-workload TLS configs; RotatingClientIdentity plus spawn_refresh rotate credentials without restart.
  • Dispatch: DispatchTarget::start returns a cancellable DispatchHandle, and dropping the dispatch() future now cancels the call.
  • Plugins: BindingSchema gains and_aliases and and_required_keys for declaring key spellings and required keys. HostPlugin::configure_egress_policy receives the operator-declared egress ceiling; its default implementation rejects one, so a plugin that cannot enforce a ceiling fails host startup.
  • Lifecycle: workloads hold the HTTP handler weakly, so dropping a Host releases its ingress; workloads with no outbound HTTP run without a live handler.

Embedder changes at 2.11.0​

  • The host-process memory limit is on by default, for every EngineBuilder embedder rather than only wash host. Unset, it is 90% of the cgroup memory limit, or of physical memory where there is no cgroup; past it, guest memory growth and new workload starts are refused. EngineBuilder::without_max_process_memory() turns it off, and with_max_process_memory_bytes sets your own. It is enforced even when guest memory is in Count mode, and it is sampled admission control rather than a hard cap, so keep an OS or container limit as the real boundary.
  • Guest memory enforces by default. GuestMemoryMode::Enforce is the new default; pass Count explicitly to keep the counting-only behavior.
  • Source-breaking API changes. ResolvedWorkload::component_count() is synchronous, so drop the .await. Adding or removing components after a workload resolves is unsupported, because the cached count and bindings would stop describing the same workload; resolve a new workload to change membership. host::http::WorkloadHandles now stores Arc<ComponentHandle> values whose fields are private, so code that populated that map directly must move registration into the Ingress/HttpHandler binding lifecycle — prefer HostBuilder::with_http_handler.
  • Pool diagnostics. Engine::pool_usage() reports used and total slots per pool kind, pool_usage::pool_refusals() counts refusals process-wide, and instantiation errors now name the setting to raise. Every pool slot reserves Wasmtime's 4 GiB memory reservation plus a 32 MiB guard regardless of --default-heap-memory, and a heap ceiling above 4 GiB now fails host startup; reduce the slot count to reduce address-space use.
  • Running a workload component as a one-shot command. ResolvedWorkload::run_command(component_id, CommandOptions) runs a component's wasi:cli/run export once in a fresh store, under the resolved workload's grants, plugins, environment, and volumes, and returns a CommandOutcome carrying the exit code. CommandOptions supplies args (including argv[0]), and optional stdin, stdout, and stderr streams. A component exporting both revisions runs as WASI 0.3, selected by the run export rather than by its imports. It does not start the workload's services or ingresses.
  • Structured payloads across the plugin store boundary. A stream<T> or future<T> crossing between a workload and a component plugin previously had to carry a scalar or string. Register the generated Wasmtime binding type with Engine::stream_payloads().register::<T>(ty) — before loading plugins or resolving workloads — and records, variants, lists, options, and results work too. Payloads carrying resources, nested streams or futures, or error contexts are still refused, since those handles belong to the source store. Native Rust plugins need no registration.
  • Plugins can override WASI builtins. See Overriding WASI builtin interfaces.
  • Attaching control to a running host. AttachedHostControl serves the operator's NATS control API for a host you built and own; see Cluster Hosts.
  • Quieter logs. Per-request, per-call, and per-binding log lines moved from INFO to DEBUG; run with RUST_LOG=wash_runtime=debug to see them again. wasi_otel bind/unbind and per-call summaries, wasmcloud:nats connection open lines, and the messaging subscriber span are all affected. HTTP ingress warnings for an invalid Host, a routing failure, or a Host with no bound workload are rate-limited to once every 10 seconds each, with a suppressed count. "Starting workload" and "Stopping workload" stay at INFO.
Volume mounts in a custom host are trusted

The hostPath allowlist described in Workload Security governs workloads the engine resolves from manifests. The public WorkloadService::new and WorkloadComponent::new constructors bypass it: mounts are opened as given, refusing only kernel filesystems. A custom host that accepts mount paths from anywhere untrusted must validate them itself.

Customizing outgoing HTTP requests​

By default, every outbound HTTP request from a component flows through the built-in client. Under Wasmtime 48 (since 2.10.0) there is a single OutgoingHandler::send_request over WasiBody serving both WASI 0.2 and 0.3 components; the earlier P3-specific types are gone. If you need to thread custom TLS configuration through outgoing calls, swap in an alternative transport, or insert middleware-style policy at the egress boundary, implement the OutgoingHandler trait as a pluggable extension point on the HTTP server. If you register none, the runtime uses DefaultOutgoingHandler.

Implement OutgoingHandler on your own type, then supply it to IngressBuilder::outgoing_handler when constructing the ingress:

rust
use wash_runtime::host::http::{Ingress, DevRouter, OutgoingHandler};

struct MyEgressHandler { /* ... */ }

impl OutgoingHandler for MyEgressHandler {
    // ...
}

let http_handler = Ingress::builder(DevRouter::default(), "127.0.0.1:8080".parse()?)
    .outgoing_handler(MyEgressHandler { /* ... */ })
    .build()
    .await?;

OutgoingHandler and wasi:tls are complementary: wasi:tls lets components own their TLS session, while OutgoingHandler lets the host shape egress uniformly across every component it runs.

Managing workloads​

Once the host is running, use the HostApi trait to manage workloads. Each WorkloadStartRequest requires a caller-supplied workload_id string.

Starting a workload​

rust
use std::collections::HashMap;
use wash_runtime::types::{Workload, WorkloadStartRequest, Component, LocalResources};

let component_bytes = std::fs::read("./my-component.wasm")?;

let request = WorkloadStartRequest {
    workload_id: uuid::Uuid::new_v4().to_string(),
    workload: Workload {
        namespace: "my-app".to_string(),
        name: "http-handler".to_string(),
        annotations: HashMap::from([
            ("version".to_string(), "1.0.0".to_string()),
        ]),
        service: None,
        components: vec![
            Component {
                name: "my-handler".to_string(),
                bytes: component_bytes.into(),
                digest: None,
                local_resources: LocalResources {
                    memory_limit_mb: 64,  // In megabytes; -1 = unlimited
                    cpu_limit: -1,        // In millicores; -1 = unlimited
                    config: HashMap::new(),
                    environment: HashMap::from([
                        ("LOG_LEVEL".to_string(), "info".to_string()),
                    ]),
                    volume_mounts: vec![],
                    allowed_hosts: vec!["api.example.com".to_string()],
                },
                pool_size: 10,
                max_invocations: 0,  // 0 = unlimited
            },
        ],
        host_interfaces: vec![],
        volumes: vec![],
    },
};

let response = host.workload_start(request).await?;
let workload_id = response.workload_status.workload_id;
println!("Workload started with ID: {}", workload_id);

Checking workload status​

rust
use wash_runtime::types::{WorkloadStatusRequest, WorkloadState};

let status = host.workload_status(WorkloadStatusRequest {
    workload_id: workload_id.clone(),
}).await?;

match status.workload_status.workload_state {
    WorkloadState::Running => println!("Workload is running"),
    WorkloadState::Error => println!("Workload encountered an error"),
    WorkloadState::Completed => println!("Workload completed"),
    state => println!("Workload state: {:?}", state),
}

Stopping a workload​

rust
use wash_runtime::types::WorkloadStopRequest;

host.workload_stop(WorkloadStopRequest {
    workload_id: workload_id.clone(),
}).await?;

Host heartbeat​

You can query the host for system information and current workload counts:

rust
let heartbeat = host.heartbeat().await?;
println!("Host: {} ({})", heartbeat.friendly_name, heartbeat.id);
println!("CPU: {:.1}%, Memory: {} MB free / {} MB total",
    heartbeat.system_cpu_usage,
    heartbeat.system_memory_free / 1024 / 1024,
    heartbeat.system_memory_total / 1024 / 1024,
);
println!("Workloads: {}, Components: {}",
    heartbeat.workload_count, heartbeat.component_count);

Complete example​

Here's a complete example that creates a custom host with HTTP and config plugins:

rust
use std::sync::Arc;
use std::collections::HashMap;
use wash_runtime::{
    engine::Engine,
    host::{HostBuilder, HostApi, http::{Ingress, DevRouter}},
    plugin::wasi_config::DynamicConfig,
    types::{Workload, WorkloadStartRequest, Component, LocalResources},
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize tracing for observability
    tracing_subscriber::fmt::init();

    // Create the engine with pooling enabled
    let engine = Engine::builder()
        .with_pooling_allocator(true)
        .build()?;

    // Configure HTTP handler and plugins
    let http_handler = Ingress::new(DevRouter::default(), "0.0.0.0:8080".parse()?).await?;
    let config_plugin = DynamicConfig::new(false);

    // Build and start the host
    let host = HostBuilder::new()
        .with_engine(engine)
        .with_friendly_name("my-custom-host")
        .with_http_handler(Arc::new(http_handler))
        .with_plugin(Arc::new(config_plugin))?
        .build()?;

    let host = host.start().await?;
    println!("Host started: {}", host.friendly_name());

    // Load a component from disk
    let component_bytes = std::fs::read("./my-component.wasm")?;

    // Create and start a workload
    let request = WorkloadStartRequest {
        workload_id: uuid::Uuid::new_v4().to_string(),
        workload: Workload {
            namespace: "default".to_string(),
            name: "my-component".to_string(),
            annotations: HashMap::new(),
            service: None,
            components: vec![Component {
                name: "my-component".to_string(),
                bytes: component_bytes.into(),
                digest: None,
                local_resources: LocalResources::default(),
                pool_size: 5,
                max_invocations: 0,
            }],
            host_interfaces: vec![],
            volumes: vec![],
        },
    };

    let response = host.workload_start(request).await?;
    let workload_id = response.workload_status.workload_id.clone();
    println!("Workload started: {}", workload_id);

    // Keep the host running
    println!("Host listening on http://0.0.0.0:8080");
    tokio::signal::ctrl_c().await?;

    // Clean shutdown
    host.workload_stop(wash_runtime::types::WorkloadStopRequest {
        workload_id,
    }).await?;

    host.stop().await?;
    println!("Host shutdown complete");
    Ok(())
}

Adding custom plugins​

For specialized requirements, you can create custom plugins that implement the HostPlugin trait. See Creating Host Plugins for detailed instructions.

rust
use std::sync::Arc;
use wash_runtime::host::HostBuilder;

// Create your custom plugin
let my_plugin = MyCustomPlugin::new();

// Register it with the host
let host = HostBuilder::new()
    .with_engine(engine)
    .with_plugin(Arc::new(my_plugin))?
    .build()?;

Error handling​

The wash-runtime APIs return anyhow::Result for flexible error handling. Common error scenarios include:

  • Engine build failures: Invalid Wasmtime configuration
  • Plugin registration: Duplicate plugin IDs
  • Workload start: Invalid component bytes or missing interface dependencies
  • Resource limits: Exceeded memory or instance limits
rust
match host.workload_start(request).await {
    Ok(response) => {
        println!("Started workload: {}", response.workload_status.workload_id);
    }
    Err(e) => {
        eprintln!("Failed to start workload: {}", e);
    }
}

Keep reading​