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.
Database tables you create and change at runtime through the API.
Versioned files on local disk or S3, with on-the-fly image transforms.
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)
Quick start
This gets a single unilayer instance running locally with Docker, then creates your first collection.
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/valkeyBuild the image
The Dockerfile packages the published output, so publish first:
dotnet publish src/unilayer -c Release -o publish docker build -t unilayer .
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
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"
http://localhost:5380/api/v1 for the interactive REST reference, or /api/v1/graphql/ui for the GraphQL explorer.
Requirements
| Component | Version | Purpose |
|---|---|---|
| .NET SDK / runtime | 10.0 | Build and run the server (not needed when using the Docker image) |
| PostgreSQL | 14+ | System tables and all collection data |
| Valkey | 7.0+ | Caching (Redis-compatible) |
| S3 / MinIO | optional | Asset 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.yamlin 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
| Key | Environment variable | Default | Description |
|---|---|---|---|
endpoint | UNILAYER_ENDPOINT | 0.0.0.0:5380 | Address and port the API listens on |
authentication:staticApiToken | UNILAYER_AUTHENTICATION_STATICAPITOKEN | – | Bootstrap token for the root user (see permissions) |
tls:certificatePath | UNILAYER_TLS_CERTIFICATEPATH | – | PKCS#12 certificate. When set, the API is served over HTTPS |
tls:certificatePassword | UNILAYER_TLS_CERTIFICATEPASSWORD | – | Password of the certificate |
Database
| Key | Environment variable | Default | Description |
|---|---|---|---|
database:providerType | UNILAYER_DATABASE_PROVIDERTYPE | PostgreSQL | Database provider |
database:connectionString | UNILAYER_DATABASE_CONNECTIONSTRING | – | Npgsql connection string |
Valkey
| Key | Environment variable | Default |
|---|---|---|
valkey:host | UNILAYER_VALKEY_HOST | localhost |
valkey:port | UNILAYER_VALKEY_PORT | 6379 |
valkey:password | UNILAYER_VALKEY_PASSWORD | – |
valkey:database | UNILAYER_VALKEY_DATABASE | 0 |
valkey:ssl | UNILAYER_VALKEY_SSL | false |
valkey:connectTimeout | UNILAYER_VALKEY_CONNECTTIMEOUT | 5000 ms |
valkey:syncTimeout | UNILAYER_VALKEY_SYNCTIMEOUT | 5000 ms |
Storage
| Key | Environment variable | Default | Description |
|---|---|---|---|
storage:provider | UNILAYER_STORAGE_PROVIDER | local | local or minio (any S3-compatible store) |
local:path | UNILAYER_LOCAL_PATH | ./storage/assets | Base directory for local storage |
s3:endpoint | UNILAYER_S3_ENDPOINT | – | S3 / MinIO endpoint URL |
s3:accessKey | UNILAYER_S3_ACCESSKEY | – | Access key |
s3:secretKey | UNILAYER_S3_SECRETKEY | – | Secret key |
s3:bucketName | UNILAYER_S3_BUCKETNAME | unilayer-assets | Bucket name |
s3:region | UNILAYER_S3_REGION | us-east-1 | Region |
s3:basePath | UNILAYER_S3_BASEPATH | assets | Key prefix inside the bucket |
s3:useSsl | UNILAYER_S3_USESSL | false | Use HTTPS towards the store |
s3:createBucketIfNotExists | UNILAYER_S3_CREATEBUCKETIFNOTEXISTS | true | Create the bucket on startup |
s3:publicUrlBase | UNILAYER_S3_PUBLICURLBASE | – | Public base URL for asset links |
GraphQL and cluster settings are described in their own sections: GraphQL, Cluster mode.
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
| Type | Aliases | Holds |
|---|---|---|
TEXT | STRING, VARCHAR, CHAR, NCHAR, NVARCHAR | Text. Returned exactly as stored, even if it looks like JSON |
INTEGER | INT, SMALLINT, BIGINT, LONG | Whole numbers |
REAL | FLOAT, DOUBLE, DECIMAL, NUMERIC | Floating-point numbers |
BOOLEAN | BIT | True / false |
DATETIME | TIMESTAMP, DATE, TIME | Date and time values |
JSON | JSONB | A 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.
| Field | Type | Meaning |
|---|---|---|
unilayer_id | UUID | Primary key |
unilayer_index | BIGSERIAL | Insertion order, the default sort |
unilayer_created_at | TIMESTAMP | Creation time |
unilayer_updated_at | TIMESTAMP | Last 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.
| Parameter | Example | Meaning |
|---|---|---|
filter | age:gt:18,status:eq:active | Conditions: , means AND, | means OR |
sort | name,createdAt:desc | One or more fields, :asc / :desc |
limit | 20 | Page size (default 100) |
offset | 40 | Items to skip |
fields | id,name,profileImage.url | Field selection, including nested references |
Filter operators
Filters use the form field:operator:value.
| Operator | Meaning | Example |
|---|---|---|
eq / ne | Equals / not equals | status:ne:deleted |
gt / ge / gte | Greater than (or equal) | price:gte:100 |
lt / le / lte | Less than (or equal) | age:lt:65 |
like | Pattern match | name:like:%John% |
in | Value in list | status:in:active,pending |
isnull / notnull | Null checks | deletedAt: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
| Parameter | Values |
|---|---|
width, height | Target size in px. Aspect ratio is kept when only one is set |
scale | Multiplier, e.g. 0.3. Can't be combined with width/height |
fit | contain (default), cover, stretch |
format | webp, png, jpg, gif |
quality | 1–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
A named list of permission strings.
Bundles one or more policies.
Has exactly one role and its own API token.
Permission strings
Permissions follow the form resource[:scope[:name]]:action. A write permission includes read.
| Permission | Grants |
|---|---|
collections:read / :write | All collections |
collections:{scope}:read | Collections with a scope prefix |
collections:{scope}:{name}:write | One specific collection |
assets:read / :write | Assets |
users:read / :write | User management |
security:read / :write | Roles and policies |
First start
On first startup unilayer creates three immutable entities:
fullaccesspolicy, which contains every permission.adminrole, which is assigned thefullaccesspolicy.rootuser, which has theadminrole and usesauthentication:staticApiTokenas its token.
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 yourAuthorizationheader 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 } }
| Key | Default | Description |
|---|---|---|
graphql:enabled | true | Turn GraphQL on or off |
graphql:path | /api/v1/graphql | Endpoint path |
graphql:enableUi | true | Serve the explorer |
graphql:enableIntrospection | true | Allow schema introspection |
graphql:maxQueryDepth | 10 | Maximum query depth |
graphql:maxQueryComplexity | 5000 | Maximum query complexity |
graphql:defaultPageSize / maxPageSize | 20 / 100 | Paging |
graphql:collectionNamePatterns | * | Collections to expose (wildcards) |
graphql:excludedCollections | system_* | Collections to hide (wildcards) |
Environment variables follow the usual pattern, e.g. UNILAYER_GRAPHQL_ENABLED.
.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.
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.
Security
- HTTPS: set
tls:certificatePathand 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:providerisminio, 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
| Key | Default | Description |
|---|---|---|
cluster:enabled | false | Turn 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 / 1000 | Election timeouts |
cluster:connectTimeoutMs | half of lower | Must be shorter than the lower election timeout |
cluster:snapshotIntervalMinutes | 1440 | pg_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 a503caused 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.
masterauth to that password on every instance so replicas can follow the current primary.Operations
Health checks
| Path | Use |
|---|---|
/health | Overall status: database, Valkey and storage |
/health/live | Liveness probe |
/health/ready | Readiness 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.