---
name: azion-resolve-tenants-by-hostname-in-a-function
description: >-
  Serve every tenant from one function that reads the hostname, loads the tenant from KV Store, and reads only its rows in SQL Database.
---

# Resolve tenants by hostname in a function

You serve every tenant of a product from one function that resolves the tenant from the hostname of each request, reads the tenant's configuration from KV Store, and reads only that tenant's rows from SQL Database. Onboarding a tenant is then a data change and a domain change, with no deployment.

---

## Prerequisites

- An application with Application Accelerator on, served by a workload on the production infrastructure. To create them, refer to [Applications quickstart](/en/documentation/platform/applications/quickstart/), and to turn on Application Accelerator, refer to [Turn on Application Accelerator](/en/documentation/guides/application-performance/cache-and-purge/cache-settings/#turn-on-application-accelerator).
- A certificate on the workload that covers the tenant hostnames, such as a wildcard certificate. To request one, refer to [Request a wildcard certificate](/en/documentation/guides/application-security/tls-and-certificates/how-to-generate-a-lets-encrypt-certificate-via-api/#request-a-wildcard-certificate).
- A KV Store namespace for tenant configuration. To create one, refer to [Namespaces](/en/documentation/platform/kv-store/namespaces/#create-a-namespace).
- A SQL Database database for tenant data. To create one, refer to [Databases and queries](/en/documentation/platform/sql-database/databases-and-queries/#create-a-database).
- A personal token, for the API calls. To create one, refer to [Personal tokens](/en/documentation/guides/platform/account-and-billing/personal-tokens/).
- The [Azion CLI](/en/documentation/devtools/cli/quickstart/) installed and authorized, to store the environment variable.

The examples use `example.com` for the zone, `*.app.example.com` for the tenant hostnames, `acme.app.example.com` and `globex.app.example.com` for two tenants with the IDs `1` and `2`, `saas-tenants` for the namespace, `saas-data` for the database, and a `projects` table as the tenant data. Replace each value with yours.

---

## Store the admin token

The function writes a tenant's configuration on an admin path that checks a token held in an environment variable, so the token never enters the code.

To create the token first, run this command with a value of your own. A function reads a variable's new value only after it is deployed again, so the variable exists before the function does:

```bash
azion create variables --key TENANT_ADMIN_TOKEN --value <admin-token> --secret true
```

The command prints the UUID of the variable:

```text
Created variable with UUID <variable-uuid>
```

The [Run multi-tenant SaaS applications](/en/documentation/use-cases/build-and-run-applications/run-multi-tenant-saas-applications/) use case uses the values of this example.

---

## Create the tenant function

The function, `tenant-app`, runs on every path and does two jobs. On a tenant hostname, it resolves the tenant and answers with that tenant's projects. On `PUT /_admin/tenants/<hostname>`, it writes a tenant's configuration, which is how onboarding reaches KV Store: a key is written only from a function.

Create a function named `tenant-app` with this code, and instantiate it on the application. For the steps in each interface, refer to [Functions quickstart](/en/documentation/platform/functions/quickstart/), which creates a function and instantiates it on an application.

```javascript
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const kv = await Azion.KV.open('saas-tenants');

    // Onboarding: write one tenant's configuration, keyed by its hostname.
    if (url.pathname.startsWith('/_admin/tenants/') && request.method === 'PUT') {
      if (request.headers.get('authorization') !== `Bearer ${Azion.env.get('TENANT_ADMIN_TOKEN')}`) {
        return new Response('Unauthorized', { status: 401 });
      }
      const hostname = url.pathname.slice('/_admin/tenants/'.length);
      const tenant = await request.json();
      if (!Number.isInteger(tenant.id)) {
        return new Response('The tenant id must be an integer', { status: 400 });
      }
      await kv.put(`tenant:${hostname}`, tenant);
      return new Response('Tenant stored', { status: 201 });
    }

    // Every other request: resolve the tenant from the hostname.
    const tenant = await kv.get(`tenant:${url.hostname}`, 'json');
    if (tenant === null) {
      return new Response('Unknown tenant', { status: 404 });
    }

    // Bind the integer tenant ID, so the query returns this tenant's rows only.
    const { Database } = Azion.Sql;
    const connection = await Database.open('saas-data');
    const rows = await connection.query('select id, name from projects where tenant_id = ? order by id', [tenant.id]);
    const projects = [];
    let row = await rows.next();
    while (row) {
      projects.push({ id: row.getValue(0), name: row.getValue(1) });
      row = await rows.next();
    }
    return new Response(JSON.stringify({ tenant: tenant.name, projects }), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};
```

The tenant ID must be an integer, because a query refuses a JavaScript string as a parameter. The lookup key is the hostname, which the request always carries, because KV Store has no operation that lists keys.

The `tenant-app` instance is on the application, and it answers `404` for a hostname with no key.

The [Run multi-tenant SaaS applications](/en/documentation/use-cases/build-and-run-applications/run-multi-tenant-saas-applications/) use case uses the values of this example.

---

## Run the function on every path

Add the rule that runs the instance on every path, as [Run a function on an application](/en/documentation/guides/application-development/functions-and-runtime/serverless-functions/#add-the-rule-that-runs-the-function) describes, with these values:

- **Name**: `tenants - run tenant-app`.
- **Phase**: *Request Phase*.
- **Criterion**: `${uri}` *starts with* `/`, so every path of every tenant hostname runs the function.
- **Behavior**: *Run Function* with the `tenant-app` instance.

In the API, the body of `POST /v4/workspace/applications/<application-id>/request_rules` carries the ID of the `tenant-app` instance in `attributes.value`:

```json
{
  "name": "tenants - run tenant-app",
  "active": true,
  "criteria": [[{ "variable": "${uri}", "conditional": "if", "operator": "starts_with", "argument": "/" }]],
  "behaviors": [{ "type": "run_function", "attributes": { "value": <function-instance-id> } }]
}
```

Every request to the workload runs `tenant-app`. A new rule takes a few minutes to reach every data center.

---

## Onboard a tenant

Onboarding a tenant is four changes, and none of them is a deployment: the tenant's rows, its configuration key, its hostname on the workload, and its DNS record. This section onboards `acme.app.example.com` with the tenant ID `1`.

To onboard the tenant:

1. **Write the tenant's rows**

   Replace `<database-id>` with the ID of `saas-data`. The first statement creates the table on the first onboarding and does nothing afterward. For how the query endpoint runs statements, refer to [Create tables and query data](/en/documentation/guides/application-development/data/create-tables-sql-database/):

   ```bash
   curl --request POST \
     --url https://api.azion.com/v4/workspace/sql/databases/<database-id>/query \
     --header 'Accept: application/json' \
     --header 'Authorization: Token [TOKEN VALUE]' \
     --header 'Content-Type: application/json' \
     --data '{
     "statements": [
       "CREATE TABLE IF NOT EXISTS projects (tenant_id INTEGER, id INTEGER, name TEXT);",
       "INSERT INTO projects (tenant_id, id, name) VALUES (1, 1, '\''Website relaunch'\'');"
     ]
   }'
   ```

   The API answers `200` with one entry per statement. Read `data[].error` on each entry, because a failed statement also returns `200`.

2. **Write the tenant's configuration**

   Send the configuration to the admin path of the tenant application, on the workload domain, with your token:

   ```bash
   curl --request PUT \
     --url https://<workload-domain>/_admin/tenants/acme.app.example.com \
     --header 'Authorization: Bearer <admin-token>' \
     --header 'Content-Type: application/json' \
     --data '{"id": 1, "name": "Acme"}'
   ```

   The function answers `201` with `Tenant stored`. A request without the token answers `401`.

3. **List the hostname on the workload**

   Send every hostname the workload answers, the new one included, because `domains` replaces the list. For the same change in Azion Console or the Azion CLI, refer to [List the domain on the workload](/en/documentation/guides/platform/migration/configure-a-domain/#list-the-domain-on-the-workload):

   ```bash
   curl --request PATCH \
     --url https://api.azion.com/v4/workspace/workloads/<workload-id> \
     --header 'Accept: application/json' \
     --header 'Authorization: Token [TOKEN VALUE]' \
     --header 'Content-Type: application/json' \
     --data '{
     "domains": ["acme.app.example.com", "globex.app.example.com"]
   }'
   ```

   The API accepts the update, and the workload lists the hostname in `domains`.

4. **Point the hostname at the workload**

   In the `example.com` zone of Edge DNS, add a `CNAME` record named `acme.app` whose value is the workload domain. For the steps, refer to [Add, edit, or delete a record](/en/documentation/guides/application-security/dns/add-records/#add-a-record).

The tenant answers on `acme.app.example.com` once the workload change propagates, which takes several minutes. KV Store makes a new key visible everywhere within 60 seconds, so a tenant can answer `404` in some locations during its first minute.

The [Run multi-tenant SaaS applications](/en/documentation/use-cases/build-and-run-applications/run-multi-tenant-saas-applications/) use case uses the values of this example.

---

## Confirm each tenant sees its own data

Each check requests a tenant hostname or the workload domain:

- **A tenant sees its own data.** Request each tenant hostname:

  ```bash
  curl -s https://acme.app.example.com/
  ```

  The body names the tenant and holds only its rows:

  ```json
  {"tenant":"Acme","projects":[{"id":1,"name":"Website relaunch"}]}
  ```

  The same request to `globex.app.example.com` names `Globex` and holds none of Acme's projects.

- **An unknown hostname gets nothing.** Request the workload domain, which has no tenant key:

  ```bash
  curl -s -o /dev/null -w '%{http_code}\n' https://<workload-domain>/
  ```

  The command prints `404`.

- **The admin path refuses a request without the token.** Send the `PUT` from [Onboard a tenant](#onboard-a-tenant) without the `Authorization` header. The function answers `401`.

- **The certificate covers the tenant.** Send `GET https://api.azion.com/v4/workspace/tls/certificates/<certificate-id>`. The certificate reads `active`, because the workload uses it, and an HTTPS request to each tenant hostname completes its handshake.

A tenant that answers Azion's placeholder `404` page instead of `Unknown tenant` is still waiting for the workload change to propagate. When the function does not answer at all, turn on [Debug Rules](/en/documentation/platform/applications/main-settings/#debug-rules) to see which rules ran.

These checks confirm the [Run multi-tenant SaaS applications](/en/documentation/use-cases/build-and-run-applications/run-multi-tenant-saas-applications/) use case.

---

## Next steps

- [Run multi-tenant SaaS applications](/en/documentation/use-cases/build-and-run-applications/run-multi-tenant-saas-applications.md): The design this function belongs to: one wildcard certificate, tenant configuration in KV Store, and tenant data in SQL Database.
- [KV Store API](/en/documentation/devtools/runtime/api-reference/kv-store.md): Every method the function uses to open a namespace, and to read and write a key.
