Memos

Declarative Configuration

Describe identity providers, instance settings, and bootstrap administrators in JSON files, review the plan, and apply it in one transaction.

From 26.10, Memos can take identity providers, instance settings, and bootstrap administrators from JSON files. The files describe the state the instance should have. Memos compares them with the database, shows the difference as a plan, and applies the plan in one database transaction. Resources the files manage become read-only in the app and the API, so a change made in the admin UI cannot silently disappear at the next restart.

Use this when SSO, storage credentials, registration policy, or the first administrator should come from Docker Compose, Kubernetes, GitOps, or a secret manager. For a personal instance, the admin UI remains the simplest option.

Declarative configuration replaces the /etc/secrets loader from 0.30 and 0.31. Memos 26.10 no longer reads memos-idp-*.json or memos-instance-setting-*.json files. Convert them before upgrading: see Migrate from /etc/secrets.

How it works

PartWhat it is
Configuration filesEvery *.json file in the configuration directory, merged into one desired state
Instance databaseThe identity providers, instance settings, and users Memos actually stores
State recordA database record listing which resources the files manage. It never stores a configured value
PlanThe changes needed to make the instance match the files, with secrets redacted
ApplyThe plan written in one serializable transaction: it either completes or changes nothing

Applying the same files twice produces an empty second plan. A failed apply leaves the database and the state record as they were.

Quick start with Docker Compose

Create a configuration directory and a file holding the administrator password:

.
├── compose.yaml
├── memos-admin-password
└── memos-config/
    └── instance.json

Add memos-config/instance.json:

{
  "instanceSettings": [
    {
      "name": "instance/settings/ACCESS",
      "accessSetting": { "accessMode": "INSTANCE_ACCESS_MODE_PRIVATE" }
    },
    {
      "name": "instance/settings/GENERAL",
      "generalSetting": {
        "disallowUserRegistration": true,
        "customProfile": { "title": "Team Memos", "description": "Notes for our team" }
      }
    }
  ],
  "users": [
    {
      "name": "users/admin",
      "username": "admin",
      "role": "ADMIN",
      "password": "${file:/run/secrets/memos-admin-password}"
    }
  ],
  "lifecycle": [{ "resource": "users/admin", "preventDestroy": true, "writeOnlyVersion": 1 }]
}

Mount the directory read-only at /etc/memos/config and pass the password as a Compose secret:

services:
  memos:
    image: neosmemo/memos:stable
    ports:
      - "5230:5230"
    volumes:
      - ./memos-data:/var/opt/memos
      - ./memos-config:/etc/memos/config:ro
    secrets:
      - memos-admin-password

secrets:
  memos-admin-password:
    file: ./memos-admin-password

The container runs as UID 10001, so both the directory and the password file must be readable by that user. Check the files:

docker compose run --rm memos config validate

For an existing instance, also review what will change in its database. plan needs an existing database, so skip it on a fresh install:

docker compose run --rm memos config plan

When the plan shows what you expect, recreate the container. Memos applies the configuration at startup, before it serves requests:

docker compose up -d --force-recreate
docker compose logs memos

The startup log reports the state serial and the names of the resources that changed. It never prints configured values.

Configuration directory

Memos reads every regular file whose name ends in .json directly inside the configuration directory:

  • The directory is /etc/memos/config unless --config-dir or MEMOS_CONFIG_DIR names another.
  • Subdirectories are not read, and files with other extensions are ignored, so the directory can share a mount with unrelated files.
  • Symlinks that resolve to regular files are followed, which makes Kubernetes Secret and ConfigMap volumes work.
  • Each file must be no larger than 1 MiB.
  • A missing directory is an empty configuration. An unreadable directory or file is an error.

All files form one configuration. Split it across files however you like, but declare each resource in exactly one file: declaring the same resource name twice is an error that names both files. Renaming a file does not change which resource it manages.

File format

Each file is one JSON object with any of these fields:

FieldContains
identityProvidersOAuth2 identity providers
instanceSettingsInstance setting groups
usersBootstrap users, usually administrators
lifecycleRules that adjust how a declared resource is planned
removedResources to stop managing or delete

Resources use the same JSON shape and field names as the Memos API, and every resource is identified by its API resource name, such as identity-providers/company-sso or instance/settings/GENERAL. A file written for one release therefore follows the API compatibility policy: fields are deprecated for at least one release before they are removed, and Memos logs a warning when a file uses a deprecated field.

Decoding is strict:

  • Unknown fields are errors. This catches misspellings and files written for a newer Memos.
  • Output-only fields, such as createTime or apiKeySet, are errors.
  • Enum values must be written by name, such as "OAUTH2", not by number.

Secrets

A string value written as ${env:NAME} is replaced with that environment variable, and ${file:/absolute/path} with that file's contents, trailing line breaks removed. Use references so the configuration files contain no secrets and can be committed or baked into an image:

"clientSecret": "${env:OIDC_CLIENT_SECRET}"
  • A reference must be the whole string value. A reference inside a longer string is an error.
  • An environment variable set to an empty string is a valid empty value. An unset variable, an unreadable file, or a referenced file larger than 64 KiB is an error.
  • Write $${ for a literal ${.
  • Errors name the reference, never the value.

Plain secret values in a file are still allowed when the file itself is mounted as a secret.

Identity providers

An identity provider is managed whole: an omitted field takes its default value rather than the stored one.

{
  "identityProviders": [
    {
      "name": "identity-providers/company-sso",
      "type": "OAUTH2",
      "title": "Company SSO",
      "identifierFilter": "",
      "config": {
        "oauth2Config": {
          "clientId": "memos",
          "clientSecret": "${env:OIDC_CLIENT_SECRET}",
          "authUrl": "https://idp.example.com/oauth/authorize",
          "tokenUrl": "https://idp.example.com/oauth/token",
          "userInfoUrl": "https://idp.example.com/oauth/userinfo",
          "scopes": ["openid", "profile", "email"],
          "fieldMapping": {
            "identifier": "sub",
            "displayName": "name",
            "email": "email",
            "avatarUrl": "picture"
          }
        }
      }
    }
  ]
}
  • The ID after identity-providers/ is 1 to 36 letters, digits, or hyphens, starting and ending with a letter or digit. Keep it stable: accounts that signed in through the provider are linked to it.
  • type is OAUTH2, and title is required.
  • clientId, absolute HTTP(S) authUrl, tokenUrl, and userInfoUrl, and fieldMapping.identifier are required. clientSecret and scopes are optional because public clients have neither.

The OAuth2 callback URL is https://<your-instance>/auth/callback. See Authentication for provider setup and identity mapping.

Instance settings

Each instance setting names its group and carries the matching settings object:

Resource nameSettings field
instance/settings/ACCESSaccessSetting
instance/settings/GENERALgeneralSetting
instance/settings/STORAGEstorageSetting
instance/settings/MEMO_RELATEDmemoRelatedSetting
instance/settings/USAGE_LIMITSusageLimitsSetting
instance/settings/NOTIFICATIONnotificationSetting
instance/settings/AIaiSetting

A setting group is managed whole: the file is the complete group, never a patch, and an omitted field takes its default value. To leave one field editable in the app, release it with ignoreChanges. The BASIC and TAGS groups cannot be configured.

Access policy

{
  "name": "instance/settings/ACCESS",
  "accessSetting": { "accessMode": "INSTANCE_ACCESS_MODE_PRIVATE" }
}

Use INSTANCE_ACCESS_MODE_PUBLIC to allow eligible anonymous public reads. The mode must be PRIVATE or PUBLIC; an omitted or unspecified mode is rejected. This setting does not revoke existing share links.

Set MEMOS_INSTANCE_URL separately to the canonical external HTTP(S) URL. A private instance can have a canonical URL behind a reverse proxy. See Environment Variables.

General

{
  "name": "instance/settings/GENERAL",
  "generalSetting": {
    "disallowUserRegistration": false,
    "disallowPasswordAuth": false,
    "disallowChangeUsername": false,
    "disallowChangeNickname": false,
    "weekStartDayOffset": 1,
    "additionalStyle": "",
    "additionalScript": "",
    "customProfile": {
      "title": "Team Memos",
      "description": "Notes for our team",
      "logoUrl": "https://example.com/logo.png"
    }
  }
}

weekStartDayOffset accepts 0 for Sunday through 6 for Saturday.

Storage

Declare named storage configurations in storages and select the one for new uploads with defaultStorageId. Existing attachments keep using the configuration they were uploaded with.

{
  "name": "instance/settings/STORAGE",
  "storageSetting": {
    "filepathTemplate": "assets/{timestamp}_{uuid}_{filename}",
    "defaultStorageId": "team-assets",
    "storages": [
      {
        "id": "team-assets",
        "name": "Team assets",
        "type": "S3",
        "s3Config": {
          "accessKeyId": "${env:S3_ACCESS_KEY_ID}",
          "accessKeySecret": "${env:S3_SECRET_ACCESS_KEY}",
          "endpoint": "https://s3.example.com",
          "region": "us-east-1",
          "bucket": "memos-assets",
          "usePathStyle": false,
          "insecureSkipTlsVerify": false
        }
      }
    ]
  }
}
  • type is DATABASE, LOCAL, or S3.
  • Storage IDs must be unique and non-empty, and defaultStorageId must name a declared storage.
  • Every S3 storage needs an endpoint, region, bucket, and both keys.
  • Keep every configuration that existing attachments still reference. Changing the default affects new uploads only.
  • The legacy storageType and top-level s3Config fields remain for compatibility; new files should use storages.

Only enable insecureSkipTlsVerify for a trusted S3-compatible endpoint with a self-signed certificate. It disables TLS certificate verification. See Storage.

Memo behavior

{
  "name": "instance/settings/MEMO_RELATED",
  "memoRelatedSetting": { "reactions": ["👍", "❤️", "🎉"] }
}

reactions is the list of allowed reactions. Memo length is a usage limit, and double-click to edit is a per-browser preference under Settings › Preferences › This device.

Usage limits

USAGE_LIMITS caps memo length, upload size, memo count, and total storage. Entries are checked in order: each entry except the last names users, the last sets allUsers: true, and a user gets the first entry that names them.

{
  "name": "instance/settings/USAGE_LIMITS",
  "usageLimitsSetting": {
    "limits": [
      { "users": ["users/admin"], "uploadBytes": "268435456" },
      {
        "allUsers": true,
        "memoContentBytes": "8192",
        "uploadBytes": "33554432",
        "storageBytes": "1073741824"
      }
    ]
  }
}
FieldLimits
memoCountMemos a user has created, including comments and archived memos
memoContentBytesUTF-8 size of one memo's content
uploadBytesSize of one uploaded file
storageBytesTotal size of a user's uploads; external links do not count

Values are byte or memo counts written as strings. An omitted metric or 0 means unlimited, and administrators are limited like everyone else. Without this setting, Memos allows 8 KiB per memo and 32 MiB per file, with no count or storage limit. A limit refuses new saves and uploads only; nothing already stored is hidden or removed.

Users are named by resource name. A plan fails when a named user does not exist, and while the setting is managed, the app refuses to rename or delete a user it names. Remove the user from the file and apply first.

Notifications

{
  "name": "instance/settings/NOTIFICATION",
  "notificationSetting": {
    "email": {
      "enabled": true,
      "smtpHost": "smtp.example.com",
      "smtpPort": 587,
      "smtpUsername": "memos",
      "smtpPassword": "${env:SMTP_PASSWORD}",
      "fromEmail": "memos@example.com",
      "fromName": "Memos",
      "replyTo": "support@example.com",
      "useTls": true,
      "useSsl": false
    }
  }
}

When email is enabled, smtpHost, a positive smtpPort, and fromEmail are required. Do not enable both useTls and useSsl.

AI providers

{
  "name": "instance/settings/AI",
  "aiSetting": {
    "providers": [
      {
        "id": "openai-main",
        "title": "OpenAI",
        "type": "OPENAI",
        "endpoint": "https://api.openai.com/v1",
        "apiKey": "${env:OPENAI_API_KEY}"
      }
    ],
    "transcription": {
      "providerId": "openai-main",
      "model": "gpt-4o-mini-transcribe",
      "language": "en",
      "prompt": ""
    }
  }
}

Every provider needs a unique id, a title, a type of OPENAI or GEMINI, and an apiKey. transcription.providerId must name a provider in the same setting. An empty OpenAI endpoint defaults to https://api.openai.com/v1; an empty Gemini endpoint defaults to https://generativelanguage.googleapis.com/v1beta.

Bootstrap users

Users let an instance come up with its administrator before anyone opens a browser. The configuration manages only what it declares; the rest of the profile belongs to the user.

{
  "users": [
    {
      "name": "users/admin",
      "username": "admin",
      "role": "ADMIN",
      "displayName": "Administrator",
      "email": "admin@example.com",
      "password": "${file:/run/secrets/memos-admin-password}"
    }
  ],
  "lifecycle": [{ "resource": "users/admin", "preventDestroy": true, "writeOnlyVersion": 1 }]
}
  • username must match the ID after users/. role is ADMIN or USER.
  • displayName and email are managed only when present. Omit them to let the user edit them; "displayName": "" manages the field as empty.
  • password is write-only. Memos sets it when the user is created and again only when writeOnlyVersion increases, so the administrator can change their password in the app without the next restart undoing it. To reset a password from the files, change the secret and raise writeOnlyVersion. A password set this way signs the user out of every session.
  • Configuration never deletes a user. Delete one in Settings after releasing it.

Lifecycle rules

A lifecycle entry adjusts how one declared resource is planned:

FieldEffect
resourceRequired. The resource name the rule applies to
ignoreChangesField paths, in the JSON names used by the file, that the configuration sets on creation but no longer manages. The app can edit them
preventDestroyA removed entry cannot destroy the resource while the last apply set this
writeOnlyVersionRaise it to send write-only fields such as a user's password again

For example, to manage the general settings while letting administrators edit custom CSS in the app:

{
  "lifecycle": [
    { "resource": "instance/settings/GENERAL", "ignoreChanges": ["generalSetting.additionalStyle"] }
  ]
}

Plan and apply

The memos config commands work against the instance database. Pass the same --data, --driver, and --dsn flags or MEMOS_* variables as the server; docker compose run --rm memos config … inherits them from the service.

CommandDoes
memos config validateDecodes and validates the files without a database. Exits 0 or 1
memos config planPrints the changes apply would make. Never writes or migrates. --json prints the plan as JSON, and --detailed-exitcode exits 0 for no changes, 1 for an error, and 2 for changes
memos config applyMigrates the database, plans, applies in one transaction, and prints the plan it applied. Nothing is written on error
memos config statePrints the state record: managed resources, the serial, and the last apply. Takes --json

Every command takes --config-dir. plan fails when the SQLite database does not exist or its schema is not the one the binary expects. apply migrates first, so it can also bootstrap a fresh instance before the server's first start.

A plan reads like Terraform's:

Plan: 1 to create, 0 to update, 0 to destroy; 1 to adopt.

  + identity-providers/company-sso  (sso.json)
  ~ instance/settings/GENERAL       (instance.json)  adopt
        generalSetting.disallowPasswordAuth: false -> true
        generalSetting.customProfile.title:  "Memos" -> "Team Memos"
ActionMeaning
createDeclared and not yet in the database
adoptDeclared and already in the database, but not yet managed; apply writes the diff shown and starts managing it
updateManaged, and the database differs from the files
driftShown on an update or create when the files did not change, so the instance was changed outside them, for example by a direct database edit or a restored backup
destroyAn identity provider deleted by a removed entry
resetA setting group returned to the defaults of a fresh instance by a removed entry
releaseNo longer managed; the database value stays as it is

Secret values print as (sensitive) and passwords as (write-only, version N).

When apply runs

By default, Memos applies the configuration every time the server starts, after database migration and before any service starts. A plan error or failed apply stops startup, so the server never runs with a configuration it could not apply.

Set --config-apply=off or MEMOS_CONFIG_APPLY=off to apply only through memos config apply, for example from a deployment job that runs before replicas roll. Replicas started with off still enforce read-only managed resources.

Multiple replicas

Replicas that start together are safe: the first to commit applies the changes, and the others find nothing to do. During a rolling update with different files, the last replica to start wins, so mount identical files on every replica, or apply from a job and run replicas with off.

A running replica can keep using a cached setting for up to ten minutes after another process applies a change; the access policy is never cached. Restart replicas after changing settings, which a rollout does anyway.

Remove a resource

A managed resource that disappears from the files is a plan error, not an implicit deletion: an accidentally deleted identity-provider block cannot lock everyone out. Declare the removal instead:

{
  "removed": [
    { "resource": "identity-providers/old-sso", "destroy": true },
    { "resource": "instance/settings/AI", "destroy": false }
  ]
}
  • destroy: false releases the resource: the files stop managing it, the database keeps its current value, and it becomes editable in the app.
  • destroy: true deletes an identity provider, or resets a setting group to its defaults. Users cannot be destroyed; release them and delete them in Settings.
  • A resource whose last apply set preventDestroy cannot be destroyed in one step. Apply once without preventDestroy, then apply the removal.

Accounts linked to a destroyed identity provider keep the link. Signing in through it works again if a provider with the same name is created later.

Once applied, a removed entry has no further effect and can be deleted from the file.

Managed resources in the app

While a resource is managed, the app refuses changes to its managed fields:

  • Settings pages show Managed by configuration with the source file and disable managed controls. Fields released by ignoreChanges, and passwords, stay editable.
  • Settings › Configuration lists the managed resources, their files and fields, and the last apply.
  • An API write that would change a managed field fails with FAILED_PRECONDITION and reason MANAGED_BY_CONFIGURATION, naming the resource, file, and fields. Edit the file and apply it instead.
  • Administrators can read the same status from GET /api/instance/configuration. It returns resource names, files, and field paths, never values.

SSO-only deployment

Declare an identity provider and, in the same configuration, set disallowPasswordAuth in the general settings:

{
  "instanceSettings": [
    {
      "name": "instance/settings/GENERAL",
      "generalSetting": { "disallowPasswordAuth": true, "disallowUserRegistration": false }
    }
  ]
}

Keep disallowUserRegistration set to false if first-time SSO users should be created automatically. A plan that disables password sign-in for regular users without leaving an identity provider fails before anything is written. Administrators can still use password sign-in for recovery.

Kubernetes

Store the configuration in a Secret, or in a ConfigMap whose secret values are ${env:…} references, and mount it at /etc/memos/config:

spec:
  containers:
    - name: memos
      image: neosmemo/memos:stable
      env:
        - name: OIDC_CLIENT_SECRET
          valueFrom:
            secretKeyRef:
              name: memos-oidc
              key: client-secret
      volumeMounts:
        - name: memos-config
          mountPath: /etc/memos/config
          readOnly: true
  volumes:
    - name: memos-config
      configMap:
        name: memos-config

Mounted files update in place, but Memos applies them only at startup or through memos config apply. After changing them, restart the workload:

kubectl rollout restart deployment/memos

For several replicas, consider a Job that runs memos config plan --detailed-exitcode and memos config apply before the rollout, with MEMOS_CONFIG_APPLY=off on the Deployment.

Migrate from /etc/secrets

Memos 0.30, 0.31, and the 26.10 release candidates up to 26.10.0-rc.2 read memos-idp-*.json and memos-instance-setting-*.json from /etc/secrets at every startup and let those files override the database without changing it. Memos 26.10 ignores those files and does not warn about them. If you upgrade without converting them, the values stored in the database take effect again: password sign-in may be re-enabled, a file-only identity provider disappears, and storage or SMTP credentials revert.

Convert every file before upgrading:

/etc/secrets fileDeclarative configuration
memos-idp-<label>.json with "uid": "primary-sso"An identityProviders entry with "name": "identity-providers/primary-sso". Keep the UID so linked accounts keep working
Identity provider "name""title"
memos-instance-setting-<group>.json with "key": "GENERAL"An instanceSettings entry with "name": "instance/settings/GENERAL" and the same settings object
Storage "type": "STORAGE_TYPE_S3""type": "S3" (also DATABASE and LOCAL)
Usage limits "userIds": [1]"users": ["users/<username>"]
Inline secretsInline values still work; prefer ${env:…} or ${file:…} references

Then:

  1. Back up the database and confirm administrator password access.
  2. Put the converted files in a new configuration directory and mount it at /etc/memos/config.
  3. Run memos config validate, then memos config plan with the 26.10 binary against a copy of the database or the upgraded instance. Expect adopt for every resource Memos already stores and create for the rest. Check the diffs: they show what the database held underneath the old files.
  4. Start 26.10 and confirm the startup log reports the applied configuration.
  5. Remove the /etc/secrets mount.

Earlier identity-provider bootstraps copied providers into the database. Declaring the same UID adopts that stored provider instead of creating a duplicate.

Security

  • Treat the configuration directory as sensitive. Mount it read-only and restrict file permissions.
  • Prefer ${env:…} and ${file:…} references to secret values in files, and never commit real credentials.
  • Plans, logs, the state record, and the configuration status never contain configured values. A database dump reveals which resources are managed, not their values.
  • memos config commands need database access and therefore the same trust as the server.
  • Keep the files with your backups so a restored instance can be configured identically, without placing secrets in a public backup.

Troubleshooting

Memos does not start

The error names the directory and lists each problem. Run memos config validate for file problems, such as unknown fields, unresolved references, or missing S3 or SMTP values, and memos config plan for problems that need the database, such as a usage limit naming a missing user or disabling password sign-in without an identity provider.

A managed resource is no longer declared

The plan reports managed but no longer declared. Add a removed entry that releases or destroys it.

A file is ignored

Check that it sits directly in the configuration directory, not in a subdirectory or /etc/secrets, and that its name ends in .json. Run memos config state to see what the instance manages.

A change in the app is refused

The resource is managed by configuration. Edit the file named in the error and apply it, or release the field with ignoreChanges.

A new password from the files does not apply

Passwords are sent only on creation and when writeOnlyVersion increases. Raise it in the user's lifecycle entry.

On this page