# Self-hosted (open source): standalone services

> Build and configure the WebVH Hosting control plane and edge server as independent services on separate hosts or ports, for distributed deployments.

By the end of this guide, you will have a distributed WebVH Hosting deployment with the control plane and edge server running as independent processes. The server registers with the control plane through a mediator.

The standalone deployment runs each service on its own port and host, enabling independent scaling, failure isolation, and placement on separate machines. Use the [unified daemon guide](/products/affinidi-elements/webvh-hosting/get-started/daemon-unified.md) instead if a single process on one host meets your needs. See the [Overview](/products/affinidi-elements/webvh-hosting/overview.md#how-it-works) for what the control plane and edge server do.

When to use this guide:

- You need to place the control plane behind a firewall, separate from public-facing servers.

- You need to scale DID resolution servers independently of the control plane.

- You are building a high-availability setup with multiple server edge nodes.

## Prerequisites

- [Rust 1.95.0 or later](https://www.rust-lang.org/tools/install) installed on the build host.

- Node.js 24.3 or later and npm on the build host. The control plane’s management UI is part of the default build.

- The DID of a running [VTA](/products/affinidi-elements/vta.md), and a workstation with PNM authenticated to that VTA. Each service’s setup wizard prints a pnm command that creates a VTA context for that service, which you run there.

- A public HTTPS URL for the edge server, reachable by DID resolvers. The control plane’s DID is published on this server.

- A URL for the control plane that its admins’ browsers can reach. It is used as the passkey (WebAuthn) origin.

- A DIDComm mediator, required for the server to register with the control plane. See [Affinidi DIDComm Mediator](/products/affinidi-elements/affinidi-messaging/didcomm-mediator.md).

The services listen over plain HTTP, so terminate TLS at a reverse proxy or load balancer in front of each public URL.

## Step 1. Build all services

Clone the repository and build the workspace:

```bash
git clone https://github.com/affinidi/did-hosting-service.git
cd did-hosting-service
cargo build --workspace --release --locked
```

Binaries are produced under target/release/:

```text
target/release/did-hosting-control
target/release/did-hosting-server
target/release/webvh-witness
target/release/webvh-watcher
target/release/did-hosting-daemon
```

The control plane binary includes the management UI by default. Copy each binary to its host, or run it from ./target/release/. The commands below assume the binaries are on your PATH.

## Step 2. Set up the control plane

Run the setup wizard on the control plane host first. The control plane’s DID is published on the server, so the server needs it before the two can connect.

```bash
did-hosting-control setup
```

When asked How will the control plane reach its VTA?, choose online, or offline for a host that cannot reach the VTA. Offline follows the same [two-phase sealed-bundle flow as the unified daemon](/products/affinidi-elements/webvh-hosting/get-started/daemon-unified.md#vta-managed-mode-offline). The online wizard prompts for:

| Prompt | What to enter |
| Config file output path | Default: config.toml. |
| DID hosting URL | The public URL of did-hosting-server, where the control plane’s DID will be resolved. |
| DID path on the server | Path on the server for the control plane DID. Default: services/control. |
| Messaging transport | DIDComm, TSP, or both. |
| VTA DID and Context ID | The DID of your VTA, and the context for the control plane. Default context: webvh. |
| DIDComm mediator | Use the VTA’s mediator or enter a mediator DID. A mediator is required. |
| Has the context been created? | Run the pnm command the wizard prints on your PNM workstation, then confirm. |
| DID log entry output file | Default: control-did.jsonl. |
| Public URL | The control plane’s own URL, used as the passkey (WebAuthn) origin. Default: http://localhost:8532. |
| Listen host and port | Defaults: 0.0.0.0 and 8532. |
| Log level and format | Defaults: info and text. |
| Data directory | Default: data/did-hosting-control. |
| Secrets storage backend | Where to store private keys. |
| Admin ACL entry | Enter an existing operator DID, generate a new did:key for the operator (the DID and private key are printed immediately, so save them), or skip and add one later with did-hosting-control add-acl. |

Note

Enter an operator DID at the Admin ACL entry prompt, not the server’s DID. The server needs the service role, which you grant in Step 5.

The wizard writes:

- The configuration file, config.toml by default.

- control-did.jsonl: the control plane’s DID log entry, which you import on the server in Step 4.

Copy the control plane DID printed on screen. You will need it in Step 3.

## Step 3. Set up the server

On the server host, run:

```bash
did-hosting-server setup
```

When asked How will the server reach its VTA?, choose online, or offline for the same two-phase sealed-bundle flow. The online wizard prompts for:

| Prompt | What to enter |
| Configuration file path | Default: config.toml. |
| Server URL | Where DIDs will be resolved publicly, for example https://did.example.com. |
| VTA DID and Context ID | The DID of your VTA, and the context for the server. |
| Has the context been created? | Run the pnm command the wizard prints on your PNM workstation, then confirm. |
| DIDComm mediator | Use the VTA’s mediator or enter a mediator DID. No mediator is also offered, but the server then cannot register with the control plane. |
| Messaging transport | Asked only when a mediator is selected. DIDComm, TSP, or both. |
| Control plane DID | Paste the control plane DID from Step 2. |
| Listen host and port | Defaults: 0.0.0.0 and 8530. |
| Log level and format | Defaults: info and text. |
| Data directory | Default: data/did-hosting-server. |
| Secrets storage backend | Where to store private keys. |

The wizard creates the server’s own DID and imports it. When the server URL has no path, the DID is served at .well-known. Otherwise the URL’s path becomes the DID path.

Copy the server DID printed on screen. You will need it in Step 5.

## Step 4. Import the control plane DID on the server

Transfer control-did.jsonl from the control plane host to the server host, then import it:

```bash
did-hosting-server bootstrap-did \
  --path services/control \
  --did-log control-did.jsonl
```

## Step 5. Authorise the server on the control plane

On the control plane host, authorise the server as a service account:

```bash
did-hosting-control add-acl --did  --role service --config config.toml
```

Replace <server-did> with the DID printed during server setup in Step 3. The control plane accepts registration only from a DID with the service role.

## Step 6. Start all services

Start each service on its own host with the configuration file its wizard wrote:

```bash
# Control plane host
did-hosting-control --config config.toml

# Server host
did-hosting-server --config config.toml
```

On startup, the server connects to its mediator and sends a registration message to the control plane, listing the DIDs it already holds. The control plane accepts the registration because the server’s DID has the service role.

## Confirm

### Test 1: Server resolves its own DID

```bash
curl -s https://did.example.com/.well-known/did.jsonl
```

Expected output: the server’s did:webvh log in JSONL format. If your server URL has a path, request <server-url>/did.jsonl instead.

### Test 2: Control plane health check returns ok

```bash
curl -s http://control.internal:8532/api/health
```

Expected output:

```json
{"status":"ok","service":"did-hosting-control","version":""}
```

### Test 3: Server appears in the control plane registry

Sign in to the control plane’s management UI as an admin and open the Servers page. Expected result: the server is listed.

Alternatively, call the registry endpoint with an admin access token from the control plane’s REST authentication:

```bash
curl -s -H "Authorization: Bearer " \
  http://control.internal:8532/api/control/registry
```

Expected output: an entry with "serviceType": "server" and the server’s DID under metadata.did.

## Troubleshooting

| Symptom | Likely cause | Fix |
| The server never registers, and its logs show no registering with control plane via DIDComm line | The control plane DID was not entered during server setup. | Set control_did in the server’s configuration file and restart the server. |
| Server logs timed out waiting for mediator connection — skipping registration | The mediator did not connect within 30 seconds. | Confirm the mediator is reachable from the server host, then restart the server. |
| The server never registers after setup chose No mediator | With no mediator, the server starts no messaging service and skips registration. | Set mediator_did, enable didcomm or tsp under [features] in the server’s configuration file, and restart the server. |
| Control plane logs server registration rejected: Service role required or DID not in ACL | The server’s DID is missing from the control plane ACL, or has a role other than service. | Run did-hosting-control add-acl --did <server-did> --role service. If an entry with another role exists, remove it first with did-hosting-control remove-acl --did <server-did>. |
| 404 on /.well-known/did.jsonl | The server’s DID was imported at a different path, or not imported. | Run did-hosting-server list-dids to see the imported DIDs. Import the server’s DID with did-hosting-server bootstrap-did, which defaults to .well-known. Re-running setup mints a new DID instead. |

## Next steps

  [Create your first hosted DID](/products/affinidi-elements/webvh-hosting/get-started/create-your-first-did.md)

  [Create and manage DIDs](/products/affinidi-elements/webvh-hosting/webvh-management/did-management.md)
