---
title: "Architecture"
description: "The three services behind wervt and how apps are isolated."
canonical_url: "https://wervt.app/self-hosting/architecture"
---
# Architecture

> The three services behind wervt and how apps are isolated.

<table>
<thead>
  <tr>
    <th>
      Service
    </th>
    
    <th>
      Image
    </th>
    
    <th>
      Role
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Runtime
    </td>
    
    <td>
      <code>
        ghcr.io/supabase/edge-runtime
      </code>
    </td>
    
    <td>
      Main worker plus one isolate per active app
    </td>
  </tr>
  
  <tr>
    <td>
      Postgres
    </td>
    
    <td>
      <code>
        postgres:18
      </code>
      
       (<code>
        wal_level=logical
      </code>
      
      )
    </td>
    
    <td>
      One database <code>
        wervt
      </code>
      
      , one schema and role per app
    </td>
  </tr>
  
  <tr>
    <td>
      Electric
    </td>
    
    <td>
      <code>
        electricsql/electric
      </code>
    </td>
    
    <td>
      Streams table changes to shapes
    </td>
  </tr>
  
  <tr>
    <td>
      Logs
    </td>
    
    <td>
      <code>
        victoriametrics/victoria-logs
      </code>
    </td>
    
    <td>
      App and runtime logs, 14 days
    </td>
  </tr>
  
  <tr>
    <td>
      Storage
    </td>
    
    <td>
      <code>
        dxflrs/garage
      </code>
    </td>
    
    <td>
      S3 object storage, one bucket and key per app
    </td>
  </tr>
</tbody>
</table>

## Main worker

`runtime/main` is the only long-running code. For each request it:

1. reads the app name from the first label of the host (`todos.wervt.app` → `todos`),
2. serves the file from `apps/<name>/public` if one matches, with immutable caching for `/_nuxt/`,
3. otherwise reads `apps/<name>/wervt.json` and hands the request to the app's isolate
(`EdgeRuntime.userWorkers.create`), passing the app's environment.

An app is a directory with the Nitro `deno-server` output:

```bash
apps/todos/
├── server/      # .output/server, entry index.mjs
├── public/      # .output/public
└── wervt.json   # { "database": true }
```

## Isolate limits

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Memory
    </td>
    
    <td>
      150 MB
    </td>
  </tr>
  
  <tr>
    <td>
      CPU per request
    </td>
    
    <td>
      10 s soft, 20 s hard
    </td>
  </tr>
  
  <tr>
    <td>
      Idle timeout
    </td>
    
    <td>
      5 minutes
    </td>
  </tr>
</tbody>
</table>

## Environment

Apps with a database receive `DATABASE_URL` (their own role), `WERVT_APP`, `WERVT_TOKEN` and
`WERVT_RUNTIME_URL`. Role passwords and app tokens are HMACs of the app name with
`WERVT_DB_SECRET`, so the runtime and the deploy tooling derive the same values without storing
them.

With storage configured, every app also receives `WERVT_S3_URL`, `WERVT_S3_PUBLIC_URL`,
`WERVT_S3_BUCKET` and its own `WERVT_S3_ACCESS_KEY_ID` / `WERVT_S3_SECRET_ACCESS_KEY`, derived the same
way. Garage itself enforces that a key only works on its app's bucket.

## Electric gateway

Apps never get Electric's secret. A shape route calls the runtime at `/_wervt/electric/v1/shape`
with its app token. The gateway:

- checks the token against the app name,
- only accepts an unqualified table name and pins it to `app_<name>.<table>`,
- forwards `where`, `params`, `columns` and Electric's protocol parameters,
- adds the Electric secret and streams the response back.

An app can therefore only ever stream its own tables, whatever its code does.

## Logs

The runtime also starts an event worker (`runtime/events`), which receives every isolate's console
output and lifecycle events (boot, boot failure, uncaught exception, shutdown). It takes the app and
release from the isolate's path, so an app can't log under another's name, parses JSON lines into
fields, and sends batches to VictoriaLogs with one stream per app. VictoriaLogs has no auth and is
only reachable inside the stack; the control plane reads it and restricts every query to one app with
an extra stream filter, which holds for `OR`, subqueries and `union` too.


## Sitemap

See the full [sitemap](https://wervt.app/sitemap.md) for all pages.
