Documentation

Build on unilayer

unilayer is an open-source, self-hosted backend layer. It puts your data, files and users behind one secure API, so your applications talk to a single, consistent interface instead of to databases, buckets and auth systems directly.

01 / Collections

Database tables you create and change at runtime through the API.

02 / Assets

Versioned files on local disk or S3, with on-the-fly image transforms.

03 / Users

Token-based users with roles, policies and fine-grained permissions.

How it fits together

Your apps call unilayer over REST or GraphQL. unilayer stores structured data in PostgreSQL, caches hot reads in Valkey, and keeps files on the local filesystem or any S3-compatible store such as MinIO.

  Your apps  (Blazor · Web · Mobile · Services)
        │  REST · GraphQL · Bearer token
        ▼
  unilayer API  ── auth · permissions · cache · validation
        │
   ┌────┼──────────────┐
   ▼    ▼              ▼
PostgreSQL  Valkey   Local disk / S3 (MinIO)
5 minutes

Quick start

This gets a single unilayer instance running locally with Docker, then creates your first collection.

1

Start PostgreSQL and Valkey

docker network create unilayer

docker run -d --name unilayer-db --network unilayer \
  -e POSTGRES_DB=unilayer -e POSTGRES_USER=unilayer \
  -e POSTGRES_PASSWORD=change-me postgres:18

docker run -d --name unilayer-cache --network unilayer valkey/valkey
2

Build the image

The Dockerfile packages the published output, so publish first:

dotnet publish src/unilayer -c Release -o publish
docker build -t unilayer .
3

Run unilayer

Every setting can be passed as an environment variable. Pick a long random string for the static API token: it becomes the root user's token.

docker run -d --name unilayer --network unilayer -p 5380:5380 \
  -e UNILAYER_ENDPOINT=0.0.0.0:5380 \
  -e UNILAYER_AUTHENTICATION_STATICAPITOKEN=my-root-token \
  -e UNILAYER_DATABASE_CONNECTIONSTRING="Host=unilayer-db;Database=unilayer;Username=unilayer;Password=change-me" \
  -e UNILAYER_VALKEY_HOST=unilayer-cache \
  -e UNILAYER_STORAGE_PROVIDER=local \
  unilayer

The database schema is migrated automatically on startup. Check that everything is up:

curl http://localhost:5380/health
4

Create a collection and add data

TOKEN=my-root-token

# Create a "tasks" collection – unilayer creates the table for you
curl -X POST http://localhost:5380/api/v1/collections \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"tasks","fields":[
        {"name":"title","type":"TEXT","nullable":false},
        {"name":"done","type":"BOOLEAN"}]}'

# Add an item
curl -X POST http://localhost:5380/api/v1/collections/tasks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Try unilayer","done":false}'

# Read it back
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:5380/api/v1/collections/tasks?filter=done:eq:false"
Explore further: open http://localhost:5380/api/v1 for the interactive REST reference, or /api/v1/graphql/ui for the GraphQL explorer.

Requirements

ComponentVersionPurpose
.NET SDK / runtime10.0Build and run the server (not needed when using the Docker image)
PostgreSQL14+System tables and all collection data
Valkey7.0+Caching (Redis-compatible)
S3 / MinIOoptionalAsset storage; required in cluster mode

Installation

Backing services

Install PostgreSQL and Valkey with your platform's package manager, or run them as containers.

PostgreSQL

# macOS
brew install postgresql@16 && brew services start postgresql@16

# Ubuntu / Debian
sudo apt install postgresql postgresql-contrib
sudo systemctl enable --now postgresql

Then create a database and user:

CREATE DATABASE unilayer;
CREATE USER unilayer_user WITH PASSWORD 'your_secure_password';
GRANT ALL PRIVILEGES ON DATABASE unilayer TO unilayer_user;

Valkey

# macOS
brew install valkey && brew services start valkey

# Ubuntu / Debian
sudo apt install valkey-server
sudo systemctl enable --now valkey-server

# Docker
docker run -d --name valkey -p 6379:6379 -v valkey-data:/data valkey/valkey

MinIO (optional)

docker run -d -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"

Run from source

Create an appsettings.yaml (see Configuration) in the working directory, then:

dotnet run --project src/unilayer

The API listens on http://localhost:5380 by default.

Run with Docker

dotnet publish src/unilayer -c Release -o publish
docker build -t unilayer .
docker run -d -p 5380:5380 -e UNILAYER_ENDPOINT=0.0.0.0:5380 ... unilayer

The image ships with pg_dump/pg_restore for cluster snapshots. They must be at least as new as your PostgreSQL server; the default is version 18. Override it with --build-arg PG_CLIENT_VERSION=<major>.

Configuration

unilayer reads its settings from three places:

  • YAML file: appsettings.yaml in the working directory is loaded automatically. Point to another file with --config=<path>.
  • Environment variables: every key has an UNILAYER_* equivalent, which suits containers.
  • Command-line arguments: e.g. --valkey:host=cache.local.

Need a YAML file from environment variables? scripts/generate-config.sh <output.yaml> builds one for you.

Example appsettings.yaml

endpoint: "0.0.0.0:5380"

authentication:
  staticApiToken: "your-secure-api-token"

database:
  providerType: "PostgreSQL"
  connectionString: "Host=localhost;Database=unilayer;Username=unilayer_user;Password=...;Port=5432"

valkey:
  host: "localhost"
  port: 6379
  password: ""
  database: 0

storage:
  provider: "local"        # or "minio"

local:
  path: "./storage/assets"

# s3:
#   endpoint: "http://localhost:9000"
#   accessKey: "minioadmin"
#   secretKey: "minioadmin"
#   bucketName: "unilayer-assets"

Core

KeyEnvironment variableDefaultDescription
endpointUNILAYER_ENDPOINT0.0.0.0:5380Address and port the API listens on
authentication:staticApiTokenUNILAYER_AUTHENTICATION_STATICAPITOKEN–Bootstrap token for the root user (see permissions)
tls:certificatePathUNILAYER_TLS_CERTIFICATEPATH–PKCS#12 certificate. When set, the API is served over HTTPS
tls:certificatePasswordUNILAYER_TLS_CERTIFICATEPASSWORD–Password of the certificate

Database

KeyEnvironment variableDefaultDescription
database:providerTypeUNILAYER_DATABASE_PROVIDERTYPEPostgreSQLDatabase provider
database:connectionStringUNILAYER_DATABASE_CONNECTIONSTRING–Npgsql connection string

Valkey

KeyEnvironment variableDefault
valkey:hostUNILAYER_VALKEY_HOSTlocalhost
valkey:portUNILAYER_VALKEY_PORT6379
valkey:passwordUNILAYER_VALKEY_PASSWORD–
valkey:databaseUNILAYER_VALKEY_DATABASE0
valkey:sslUNILAYER_VALKEY_SSLfalse
valkey:connectTimeoutUNILAYER_VALKEY_CONNECTTIMEOUT5000 ms
valkey:syncTimeoutUNILAYER_VALKEY_SYNCTIMEOUT5000 ms

Storage

KeyEnvironment variableDefaultDescription
storage:providerUNILAYER_STORAGE_PROVIDERlocallocal or minio (any S3-compatible store)
local:pathUNILAYER_LOCAL_PATH./storage/assetsBase directory for local storage
s3:endpointUNILAYER_S3_ENDPOINT–S3 / MinIO endpoint URL
s3:accessKeyUNILAYER_S3_ACCESSKEY–Access key
s3:secretKeyUNILAYER_S3_SECRETKEY–Secret key
s3:bucketNameUNILAYER_S3_BUCKETNAMEunilayer-assetsBucket name
s3:regionUNILAYER_S3_REGIONus-east-1Region
s3:basePathUNILAYER_S3_BASEPATHassetsKey prefix inside the bucket
s3:useSslUNILAYER_S3_USESSLfalseUse HTTPS towards the store
s3:createBucketIfNotExistsUNILAYER_S3_CREATEBUCKETIFNOTEXISTStrueCreate the bucket on startup
s3:publicUrlBaseUNILAYER_S3_PUBLICURLBASE–Public base URL for asset links

GraphQL and cluster settings are described in their own sections: GraphQL, Cluster mode.

Concepts

Collections

A collection is a real PostgreSQL table that you define through the API. Creating, changing or deleting a collection changes the table at runtime; there are no migrations to write. Every collection has explicitly typed fields.

Field types

TypeAliasesHolds
TEXTSTRING, VARCHAR, CHAR, NCHAR, NVARCHARText. Returned exactly as stored, even if it looks like JSON
INTEGERINT, SMALLINT, BIGINT, LONGWhole numbers
REALFLOAT, DOUBLE, DECIMAL, NUMERICFloating-point numbers
BOOLEANBITTrue / false
DATETIMETIMESTAMP, DATE, TIMEDate and time values
JSONJSONBA JSON object or array, returned as JSON

Fields can also be nullable, have a default value, and be unique. Field names are checked against PostgreSQL reserved words.

System fields

unilayer manages four fields on every collection. The unilayer_ prefix is reserved, so you can't define fields that start with it.

FieldTypeMeaning
unilayer_idUUIDPrimary key
unilayer_indexBIGSERIALInsertion order, the default sort
unilayer_created_atTIMESTAMPCreation time
unilayer_updated_atTIMESTAMPLast update, or null

Responses keep these separate from your data: your fields are in data and system values are in metadata, so names can never collide.

Relationships

Mark a field with isReference: true and a referenceCollection to create a foreign key. A reference always points at the target's unilayer_id. When you read items, referenced records are expanded and nested automatically, up to 3 levels deep.

{
  "name": "users",
  "fields": [
    { "name": "username", "type": "TEXT" },
    { "name": "profileImage", "type": "TEXT",
      "isReference": true, "referenceCollection": "images" }
  ]
}

Schema history

Every schema change is written to a system history. You can export this history and replay it on another installation to move a collection structure between sites. See Operations.

Querying

The same query language works on every collection: filter, sort, paginate and pick fields using query parameters.

ParameterExampleMeaning
filterage:gt:18,status:eq:activeConditions: , means AND, | means OR
sortname,createdAt:descOne or more fields, :asc / :desc
limit20Page size (default 100)
offset40Items to skip
fieldsid,name,profileImage.urlField selection, including nested references

Filter operators

Filters use the form field:operator:value.

OperatorMeaningExample
eq / neEquals / not equalsstatus:ne:deleted
gt / ge / gteGreater than (or equal)price:gte:100
lt / le / lteLess than (or equal)age:lt:65
likePattern matchname:like:%John%
inValue in liststatus:in:active,pending
isnull / notnullNull checksdeletedAt:isnull:

Values are parsed according to the field's type and always sent to the database as parameters. Field names and operators are checked against the schema and an allow-list, so filters are safe from SQL injection.

Assets

Assets are files with metadata. Each asset has a UID, name, file type, MIME type, size, a SHA-256 checksum and a version number that increases every time the content is replaced. You can attach custom metadata as JSON. Deleting an asset is a soft delete, so admins can restore it.

File types

An asset's type comes from its file extension: Document (pdf), Image (png, jpg, jpeg, svg, gif, webp), Video (mp4) and Text (txt or no extension). Other extensions are rejected. The default upload limit is 100 MB per file.

Image transforms

Images can be resized and re-encoded on download by adding query parameters to the asset URL, so there is no need to store separate thumbnails.

/api/v1/assets/{uid}?width=200&format=webp
/api/v1/assets/{uid}?width=300&height=300&fit=cover&quality=70
/api/v1/assets/{uid}?scale=0.3
ParameterValues
width, heightTarget size in px. Aspect ratio is kept when only one is set
scaleMultiplier, e.g. 0.3. Can't be combined with width/height
fitcontain (default), cover, stretch
formatwebp, png, jpg, gif
quality1–100 (default 80), for jpg and webp
  • EXIF, XMP, IPTC and ICC data (such as GPS positions) is stripped from transformed images.
  • Variants are cached per asset version, so uploading new content invalidates them automatically. Responses support ETag / 304.
  • Outputs are limited to 4096 px per side and sources to 50 megapixels. SVGs are returned unchanged.

Users & permissions

Every request is authenticated with a bearer token:

Authorization: Bearer <token>

The model

Policy

A named list of permission strings.

Role

Bundles one or more policies.

User

Has exactly one role and its own API token.

Permission strings

Permissions follow the form resource[:scope[:name]]:action. A write permission includes read.

PermissionGrants
collections:read / :writeAll collections
collections:{scope}:readCollections with a scope prefix
collections:{scope}:{name}:writeOne specific collection
assets:read / :writeAssets
users:read / :writeUser management
security:read / :writeRoles and policies

First start

On first startup unilayer creates three immutable entities:

  • fullaccess policy, which contains every permission.
  • admin role, which is assigned the fullaccess policy.
  • root user, which has the admin role and uses authentication:staticApiToken as its token.
The static token is used once. After bootstrap only its hash is stored in the database, and the setting is no longer needed. Use the root token to create real users, then keep it somewhere safe.
Integrations

GraphQL

unilayer serves a GraphQL API next to REST. The schema is generated from your collections and rebuilt automatically when a collection or field changes. Permissions and write paths are the same as for REST.

  • Endpoint: POST /api/v1/graphql. Authentication is required, including for introspection.
  • Explorer: /api/v1/graphql/ui. Add your Authorization header in its headers panel.
  • SDL download: GET /api/v1/graphql/schema, for codegen.
query {
  postsList(where: [{ field: views, op: GTE, value: "5" }],
            orderBy: [{ field: title }], limit: 10) {
    items { id title views createdAt }
    hasMore
  }
}

mutation {
  createPosts(input: { title: "Hello" }) { id }
}
KeyDefaultDescription
graphql:enabledtrueTurn GraphQL on or off
graphql:path/api/v1/graphqlEndpoint path
graphql:enableUitrueServe the explorer
graphql:enableIntrospectiontrueAllow schema introspection
graphql:maxQueryDepth10Maximum query depth
graphql:maxQueryComplexity5000Maximum query complexity
graphql:defaultPageSize / maxPageSize20 / 100Paging
graphql:collectionNamePatterns*Collections to expose (wildcards)
graphql:excludedCollectionssystem_*Collections to hide (wildcards)

Environment variables follow the usual pattern, e.g. UNILAYER_GRAPHQL_ENABLED.

Collection and field names are visible through introspection to every authenticated user. If that matters, disable introspection. Subscriptions, upserts and total counts are not supported yet.

.NET client

Unilayer.Client is a typed client for .NET 10. It maps your classes to collections with attributes and turns LINQ into server-side queries.

1. Install and register

dotnet add package Unilayer.Client
builder.Services.AddUnilayerClient(options =>
{
    options.Url   = "https://unilayer.example.com";
    options.Token = builder.Configuration["Unilayer:Token"];
});

// Several backends? Register named clients and use IUnilayerClientFactory.
builder.Services.AddUnilayerClient("Archive", options => { /* ... */ });

2. Describe your model

[UniCollection("products")]
public class Product
{
    [UniField("name", IsRequired = true)]
    public string Name { get; set; } = "";

    [UniField("sku", IsUnique = true)]
    public string Sku { get; set; } = "";

    [UniField("price", IsIndexed = true)]
    public decimal Price { get; set; }
}

3. Sync and query

await client.SyncCollectionSchemaAsync<Product>();   // creates / updates the collection

await client.CreateAsync(new Product { Name = "Widget", Sku = "W-1", Price = 9.90m });

var cheap = await client.Query<Product>()
    .Where(p => p.Price < 20)
    .OrderBy(p => p.Name)
    .Take(50)
    .ToListAsync();

Field encryption

Mark a property with IsEncrypted = true and set FieldEncryptionMasterKeyBase64 (32 random bytes, base64-encoded). Values are encrypted with AES-256-GCM in the client, so the server only ever stores ciphertext.

Encrypted fields can't be filtered or sorted in a meaningful way. Keep the master key stable and out of source control: if you lose it, or rename the collection or property, existing values can no longer be decrypted.

Built-in resilience

The client retries with exponential backoff (3 retries by default), has a circuit breaker and uses a 30 s timeout. All of these can be configured on UnilayerClientOptions. It also supports schema versioning and migrations for evolving models.

Production

Security

  • HTTPS: set tls:certificatePath and the API listener serves HTTPS only. In cluster mode this is mandatory.
  • Tokens: only hashes of API tokens are stored.
  • Rate limiting per client IP: 1000 requests/min globally, 50/min for collection writes, and 10/min for authentication endpoints. Clients that exceed a limit get 429.
  • Least privilege: give each app its own user and a role that is scoped down to the collections it needs.
  • Sensitive data: use client-side field encryption for values the server should never see in plain text.

Cluster mode

For high availability, run several unilayer nodes. Each node has its own PostgreSQL, Valkey and WAL directory, and all nodes share one S3 store. The nodes elect a leader with Raft, and writes are replicated through the Raft log to every node. A write sent to a follower is forwarded to the leader, and reads are served locally.

Requirements

A node refuses to start unless all of these are true:

  • storage:provider is minio, because snapshots and assets live in the shared store.
  • The public API has a TLS certificate (tls:certificatePath).
  • Node-to-node traffic uses mutual TLS with a private CA.

Certificates

CERT_PASSWORD=secret ./scripts/generate-cluster-certs.sh ./certs node1 node2 node3

Give each node its own .pfx and ca.crt, and keep ca.key off the nodes. When a certificate has less than 30 days left, /health reports Degraded so you can alert on it in time.

Settings

KeyDefaultDescription
cluster:enabledfalseTurn cluster mode on
cluster:nodeId–Unique node name
cluster:bindEndpoint–This node's address. Must be reachable (not 0.0.0.0) and listed in peers
cluster:peers–All nodes, as a YAML list or comma-separated. Use either all IPs or all hostnames
cluster:walDirectory–Raft write-ahead log directory
cluster:internalApiPort–Internal mTLS-only port, the same on every node
cluster:tlsCertificatePath–Node .pfx (serverAuth + clientAuth)
cluster:tlsCertificatePassword–Password of the .pfx
cluster:tlsCaCertificatePath–CA public certificate
cluster:valkeyLocalEndpoint–This node's Valkey, which switches to primary or replica with leadership
cluster:lowerElectionTimeoutMs / upper…500 / 1000Election timeouts
cluster:connectTimeoutMshalf of lowerMust be shorter than the lower election timeout
cluster:snapshotIntervalMinutes1440pg_dump snapshots to S3. 0 disables them

Environment variables follow the usual pattern, e.g. UNILAYER_CLUSTER_PEERS=10.0.0.1:7000,10.0.0.2:7000,10.0.0.3:7000.

Consistency

  • A write is acknowledged once a majority has it and the leader has applied it.
  • Writes are fenced by leadership. A non-leader rejects writes with 503. After a 503 caused by lost leadership, the write's outcome is unknown, so retry with care.
  • Reads on a follower can lag by about one heartbeat (~250 ms by default). To read your own write, read from the leader.
If your Valkey instances use a password, also set masterauth to that password on every instance so replicas can follow the current primary.

Operations

Health checks

PathUse
/healthOverall status: database, Valkey and storage
/health/liveLiveness probe
/health/readyReadiness probe

API reference

Every instance serves an interactive REST reference at /api/v1, and the OpenAPI document at /api/v1/openapi/{document}.json.

Moving schemas between sites

Export the collection schema history from one installation and replay it against another installation's API:

# On the source (same configuration as the running instance)
dotnet unilayer.dll --export-history-file=schema.jsonl

# Replay against the target
dotnet unilayer.dll --import-history-file=schema.jsonl \
  --import-target-url=https://target.example.com \
  --import-target-token=$TARGET_TOKEN

Use --export-from-sequence=<n> and --import-from-sequence=<n> to do this incrementally or to resume a replay.

Maintenance commands

--truncate-all (drops all user collections) and --reinitialize-database destroy data. They run once and then exit. Use them only on development instances or with a backup.