---
title: "Production"
description: "Run wervt on a server with Coolify, Cloudflare and a Hetzner Storage Box."
canonical_url: "https://wervt.app/self-hosting/production"
---
# Production

> Run wervt on a server with Coolify, Cloudflare and a Hetzner Storage Box.

The production stack is `deploy/docker-compose.yaml`, deployed by Coolify with the Docker Compose
build pack (base directory `/deploy`). Pushing to `main` deploys it.

## Services

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

<tbody>
  <tr>
    <td>
      <code>
        runtime
      </code>
    </td>
    
    <td>
      Edge Runtime: the main worker and the app isolates, <code>
        *.wervt.app
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        control
      </code>
    </td>
    
    <td>
      Control plane, <code>
        api.wervt.app
      </code>
      
      : deploys, migrations, tasks, logs, storage
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        electric
      </code>
    </td>
    
    <td>
      Live shapes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        victorialogs
      </code>
    </td>
    
    <td>
      Logs, 14 days, only on the stack's network
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        garage
      </code>
    </td>
    
    <td>
      Object storage (S3 API), <code>
        media.wervt.app
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        docs
      </code>
    </td>
    
    <td>
      These docs, <code>
        wervt.app
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        proxy-cert
      </code>
    </td>
    
    <td>
      Installs the Cloudflare Origin certificate into Coolify's Traefik
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        media-cert
      </code>
    </td>
    
    <td>
      Gets and renews the Let's Encrypt certificate for <code>
        media.wervt.app
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        storage-backup
      </code>
    </td>
    
    <td>
      Nightly copy of Garage to the Storage Box
    </td>
  </tr>
</tbody>
</table>

Postgres is a separate Coolify database (`wervt-postgres`), so Coolify backs it up. It needs
`wal_level = logical` in its custom configuration for Electric.

## Environment

Coolify generates the `SERVICE_*` values on the first deploy and keeps them. Set the rest on the
Coolify app:

<table>
<thead>
  <tr>
    <th>
      Variable
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        WERVT_PG_URL
      </code>
    </td>
    
    <td>
      Internal URL of <code>
        wervt-postgres
      </code>
      
       (superuser, database <code>
        wervt
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        WERVT_ADMIN_EMAILS
      </code>
    </td>
    
    <td>
      Emails that may sign in to private apps and the dashboard
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        WERVT_ORIGIN_CERT
      </code>
      
      , <code>
        WERVT_ORIGIN_KEY
      </code>
    </td>
    
    <td>
      The Cloudflare Origin certificate and key for <code>
        *.wervt.app
      </code>
      
       and <code>
        wervt.app
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        STORAGE_BOX_HOST
      </code>
    </td>
    
    <td>
      The Storage Box (sub-)account's host, e.g. <code>
        u123456-sub1.your-storagebox.de
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        STORAGE_BOX_USER
      </code>
      
      , <code>
        STORAGE_BOX_PASSWORD
      </code>
    </td>
    
    <td>
      Its login
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        STORAGE_BOX_DIR
      </code>
    </td>
    
    <td>
      Folder for the Garage backup, relative to the account's root (default <code>
        wervt/garage
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        CLOUDFLARE_DNS_API_TOKEN
      </code>
    </td>
    
    <td>
      Cloudflare API token for the <code>
        media.wervt.app
      </code>
      
       certificate: <strong>
        Zone > Zone > Read
      </strong>
      
       and <strong>
        Zone > DNS > Edit
      </strong>
      
      , limited to the <code>
        wervt.app
      </code>
      
       zone
    </td>
  </tr>
</tbody>
</table>

Generated, don't change them: `SERVICE_PASSWORD_64_DBSECRET` (derives every app's database password,
app token and S3 key, so changing it locks apps out of their data), `_DEPLOY` (admin token), `_READ`
(read token), `_AUTH`, `_ELECTRIC`, `_GARAGEADMIN` and `SERVICE_HEX_64_GARAGERPC`.

## DNS and TLS

<table>
<thead>
  <tr>
    <th>
      Record
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Proxied
    </th>
    
    <th>
      Certificate
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        *.wervt.app
      </code>
    </td>
    
    <td>
      A
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      Cloudflare Origin (Cloudflare in Full (strict))
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        wervt.app
      </code>
    </td>
    
    <td>
      A
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      Cloudflare Origin
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        media.wervt.app
      </code>
    </td>
    
    <td>
      A
    </td>
    
    <td>
      <strong>
        no
      </strong>
    </td>
    
    <td>
      Let's Encrypt, from <code>
        media-cert
      </code>
    </td>
  </tr>
</tbody>
</table>

`media.wervt.app` serves files and video straight from the server. Cloudflare's terms don't allow
heavy video delivery through the proxy, and serving it directly keeps wervt independent of
Cloudflare for its data.

Traefik doesn't request certificates for names that a stored certificate already covers, and the
Origin certificate covers `*.wervt.app`. So `media-cert` gets the Let's Encrypt certificate itself,
with lego's DNS-01 challenge: it sets a TXT record through the Cloudflare API. Traefik answers
HTTP-01 and TLS-ALPN-01 challenges itself, so those can't reach a sidecar. `media-cert` puts the
certificate into Traefik's configuration, where it wins over the wildcard for `media.wervt.app`,
and renews it when a third of its lifetime is left.

Check it with:

```bash
echo | openssl s_client -connect media.wervt.app:443 -servername media.wervt.app 2>/dev/null \
  | openssl x509 -noout -issuer -enddate
```

### Rotating the Origin certificate

1. In Cloudflare, under **SSL/TLS > Origin Server**, create a certificate for `*.wervt.app` and
`wervt.app`.
2. Put the certificate and key into `WERVT_ORIGIN_CERT` and `WERVT_ORIGIN_KEY` in Coolify, and
deploy. `proxy-cert` writes them into Traefik's configuration, and Traefik picks them up
without a restart.
3. Check that the new one is served (its serial and start date):```bash
echo | openssl s_client -connect <server-ip>:443 -servername api.wervt.app 2>/dev/null \
  | openssl x509 -noout -serial -startdate
```
4. Revoke the old certificate in Cloudflare.

## First start

1. Create the Postgres database, set the environment above and deploy.
2. The control plane gives Garage its layout and creates a bucket for every app on start.
3. Sign in the CLI: `wervt login --url https://api.wervt.app`, then approve the code on the
dashboard's **CLI** page. Before the dashboard is deployed, use the admin token:
`wervt login --url … --token <SERVICE_PASSWORD_64_DEPLOY>`.
4. Deploy the auth app and the dashboard from the repo: `pnpm app:deploy auth`,
`pnpm app:deploy dashboard`.
5. Give the dashboard its access to the control plane. It keeps the admin token on its server
side only:```bash
wervt env dashboard --set CONTROL_URL=https://api.wervt.app --set CONTROL_TOKEN=… \
  --set DASHBOARD_ADMIN_EMAILS=you@example.com
```

<br />

Its API answers only the signed-in emails in `DASHBOARD_ADMIN_EMAILS`. Keep the app private.

Backups and restores are on [Backups](https://wervt.app/self-hosting/backups).


## Sitemap

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