Skip to content

Config Options

Reference: {#RefConfigOptions}

Fragment.

This page is about the yaml files that determine the configuration of the Wire backend services.

MLS private key paths

Note: This developer documentation. Documentation for site operators can be found here: Messaging Layer Security (MLS)

The mlsPrivateKeyPaths field should contain a mapping from purposes and signature schemes to file paths of corresponding x509 private keys in PEM format.

At the moment, the only purpose is removal, meaning that the key will be used to sign external remove proposals.

For example:

1
2
3
4
5
6
  mlsPrivateKeyPaths:
    removal:
      ed25519: /etc/secrets/ed25519.pem
      ecdsa_secp256r1_sha256: /etc/secrets/ecdsa_secp256r1_sha256
      ecdsa_secp384r1_sha384: /etc/secrets/ecdsa_secp384r1_sha384
      ecdsa_secp521r1_sha512: /etc/secrets/ecdsa_secp521r1_sha512

A simple way to generate an ed25519 private key, discarding the corresponding certificate, is to run the following command:

openssl genpkey -algorithm ed25519

ECDSA private keys can be generated with:

openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256

and similar (replace P-256 with P-384 or P-521).

Feature flags

Also see Wire docs where some of the feature flags are documented from an operations point of view.

Feature flags can be used to turn features on or off, or determine the behavior of the features. Example:

1
2
3
4
5
6
# [galley.yaml]
settings:
  featureFlags:
    sso: disabled-by-default
    teamSearchVisibility: disabled-by-default
    setEmailVisibility: visible_to_self

The featureFlags field in the galley settings is mandatory, and all features must be listed. Each feature defines its own set of allowed flag values. (The reason for that is that as we will see, the semantics is slightly different (or more specific) than boolean.)

NOTE: some feature flags can also be changed for individual teams via the backoffice app, or the internal rest API (example).

SSO

This sets the default setting for all teams, and can be overridden by customer support / backoffice. Allowed values: disabled-by-default, enabled-by-default.

IMPORTANT: if you change this from ‘enabled-by-default’ to ‘disabled-by-default’ in production, you need to run this migration script to fix all teams that have registered an idp. (if you don’t, the idp will keep working, but the admin won’t be able to register new idps.)

Expose invitation URLs to team admin

For further processing (e.g. sending custom emails or rendering the URLs as QR codes), team invitation URLs can be made part of the result of GET /teams/{tid}/invitations.

{
    "has_more": false,
    "invitations": [
        {
            "created_at": "2022-09-15T15:47:28.577Z",
            "created_by": "375f56fe-7f12-4c0c-aed8-d48c0326d1fb",
            "email": "foo@example.com",
            "id": "4decf7f8-bdd4-43b3-aaf2-e912e2c0c46f",
            "name": null,
            "phone": null,
            "role": "member",
            "team": "51612209-3b61-49b0-8c55-d21ae65efc1a",
            "url": "http://127.0.0.1:8080/register?team=51612209-3b61-49b0-8c55-d21ae65efc1a&team_code=RpxGkK_yjw8ZBegJuFQO0hha-2Tneajp"
        }
    ]
}

This can be a privacy issue as it allows the team admin to impersonate as another team member. The feature is disabled by default.

To activate this feature two steps are needed. First, the team id (tid) has to be added to the list of teams for which this feature can be enabled (exposeInvitationURLsTeamAllowlist). This is done in galley’s values.yaml:

settings:
  exposeInvitationURLsTeamAllowlist: ["51612209-3b61-49b0-8c55-d21ae65efc1a", ...]

Then, the feature can be set for the team by enabling the exposeInvitationURLsToTeamAdmin flag. This is done by making a PUT request to /teams/{tid}/features/exposeInvitationURLsToTeamAdmin with the body:

1
2
3
{
    "status": "enabled"
}

Team searchVisibility

The team flag searchVisibility affects the outbound search of user searches. If it is set to no-name-outside-team for a team then all users of that team will no longer be able to find users that are not part of their team when searching. This also includes finding other users by by providing their exact handle. By default it is set to standard, which doesn’t put any additional restrictions to outbound searches.

The setting can be changed via endpoint:

1
2
3
4
5
6
7
8
9
GET /teams/{tid}/search-visibility
  -- Shows the current TeamSearchVisibility value for the given team

PUT /teams/{tid}/search-visibility
  -- Set specific search visibility for the team

pull-down-menu "body":
  "standard"
  "no-name-outside-team"

The team feature flag teamSearchVisibility determines whether it is allowed to change the searchVisibility setting or not. The default is disabled-by-default. Note that whenever this feature setting is disabled the searchVisibility will be reset to standard.

The default setting that applies to all teams on the instance can be defined at configuration

1
2
3
settings:
  featureFlags:
    teamSearchVisibility: disabled-by-default # or enabled-by-default

TeamFeature searchVisibilityInbound

The team feature flag searchVisibilityInbound affects if the team’s users are searchable by users from other teams. The default setting is searchable-by-own-team which hides users from search results by users from other teams. If it is set to searchable-by-all-teams then users of this team may be included in the results of search queries by other users.

Note: The configuration of this flag does not affect search results when the search query matches the handle exactly. If the handle is provdided then any user on the instance can find users.

This team feature flag can only by toggled by site-administrators with direct access to the galley instance:

PUT /i/teams/{tid}/features/search-visibility-inbound
with JSON body {"status": "enabled"} or body {"status": disabled}

where enabled is equivalent to searchable-by-all-teams and disabled is equivalent to searchable-by-own-team.

The default setting that applies to all teams on the instance can be defined at configuration.

1
2
3
searchVisibilityInbound:
  defaults:
    status: enabled # OR disabled

Individual teams can overwrite the default setting.

Email Visibility

Allowd values and their description.

Classified domains

To enable classified domains, see the documentation on classified domains: classified-domains

Conference Calling

The conferenceCalling feature flag controls whether a user can initiate a conference call. The flag can be toggled between its states enabled and disabled per team via an internal endpoint.

The conferenceCalling section in featureFlags defines the state of the conferenceCalling feature flag for all personal users (users that don’t belong to a team). For personal users there is no way to toggle the flag, so the setting of the config section wholly defines the state of conferenceCalling flag for all personal users.

The conferenceCalling section in featureFlags also defines the initial state of the conferenceCalling flag for all teams. After the flag is set for the first time for a team via the internal endpoint the value from the config section will be ignored.

Example value for the config section:

1
2
3
conferenceCalling:
  defaults:
    status: enabled

The conferenceCalling section is optional in featureFlags. If it is omitted then it is assumed to be enabled.

See also: conference falling for personal accounts (below).

Meetings

The meetings feature flag controls whether a user can initiate a meetings. It is disabled and locked by default. If you want a different configuration, use the following syntax:

1
2
3
4
meetings:
  defaults:
    status: disabled|enabled
    lockStatus: locked|unlocked

The lock status for individual teams can be changed via the internal API (PUT /i/teams/:tid/features/meetings/(un)?locked).

The feature status for individual teams can be changed via the public API (if the feature is unlocked).

Meetings validity and past-edit periods

settings.meetings.validityPeriod (default 48h) is how long a meeting stays alive (readable and editable) after its effective end time — its end_time, or the end of its recurrence window for recurring meetings; open-ended recurring meetings never expire. settings.meetings.pastEditPeriod (default 24h) bounds how far into the past PUT /meetings/{domain}/{id} may move a meeting's start_time/end_time, so past and ongoing meetings can be corrected to what actually happened. Only provided time values are checked against this cutoff; unchanged stored times are not re-validated — but the effective times (provided or stored) must still satisfy end_time > start_time. Galley refuses to start if pastEditPeriod is negative or greater than validityPeriod, so a meeting edited to past times stays inside the validity window and remains visible and editable.

1
2
3
4
5
# galley.yaml
settings:
  meetings:
    validityPeriod: "48h"
    pastEditPeriod: "24h"

Meetings email sender and transport

The optional settings.meetings.email block enables emailing meeting invitations to external addresses. from is required; replyTo is optional. transport is an AWS SES or SMTP value (the same shape Brig uses for its email transport). When the block is absent, no meeting invitation emails are sent.

# galley.yaml
settings:
  meetings:
    email:
      from: meetings@example.com
      replyTo: noreply@example.com
      transport:               # SES:
        sesQueue: wire-meetings-email-feedback
        sesEndpoint: https://email.us-east-1.amazonaws.com
      # transport:             # SMTP (xor SES):
      #   smtpEndpoint: { host: smtp.example.com, port: 587 }
      #   smtpConnType: tls
      #   smtpCredentials:
      #     smtpUsername: meetings
      #     smtpPassword: /etc/wire/galley/secrets/smtp-password.txt

For SMTP, the Helm value galley.config.settings.meetings.email.smtp.passwordFile points at the path where the SMTP password is read, and galley.secrets.smtpPassword holds the value (mounted at /etc/wire/galley/secrets/smtp-password.txt). The Galley ConfigMap injects that path into transport.smtpCredentials.smtpPassword, the same pattern Brig uses for smtp.passwordFile.

Meetings time settings

The galley.config.settings.meetings.legacyTimeZone Helm value is an IANA time zone id (e.g. "Europe/Berlin", the default) used as the tzid for meetings created by legacy clients (< V17), which send an end_time instead of the V17 duration + tzid fields. It has no effect on V17+ clients.

Meetings Premium (deprecated)

Deprecated (WPB-26771). The meetingsPremium feature flag no longer affects meeting behaviour. Team meetings are always non-trial regardless of this flag's value. The flag and its data type are retained for backward compatibility.

The flag now defaults to enabled and locked and the Helm configuration override has been removed (operators can no longer change it via Helm). The MeetingsPremiumConfig type carries a DEPRECATED pragma. The public GET/PUT /teams/:tid/features/meetingsPremium endpoints and the internal lock-status endpoints have no behavioural effect and now return 404 at API version v17; they remain available through v16.

The aggregate list endpoints (GET /feature-configs, GET /teams/:tid/features) continue to include meetingsPremium at all API versions, including v17.

Background Effects (deprecated)

Deprecated (WPB-27912). The backgroundEffects feature flag no longer affects meeting behaviour. The flag, its data type and its public/internal endpoints are retained for backward compatibility and are scheduled for removal in a future release.

The flag now defaults to enabled and locked and the Helm configuration override has been removed (operators can no longer change it via Helm). The GET/PUT /teams/:tid/features/backgroundEffects and internal lock-status endpoints return 404 at API version v17; they remain available through v16. The aggregate endpoints GET /feature-configs and GET /teams/:tid/features continue to include backgroundEffects at all API versions, including v17.

File Sharing

File sharing is enabled and unlocked by default. If you want a different configuration, use the following syntax:

1
2
3
4
fileSharing:
  defaults:
    status: disabled|enabled
    lockStatus: locked|unlocked

These are all the possible combinations of status and lockStatus:

status lockStatus
enabled locked Feature enabled, cannot be disabled by team admin
enabled unlocked Feature enabled, can be disabled by team admin
disabled locked Feature disabled, cannot be enabled by team admin
disabled unlocked Feature disabled, can be enabled by team admin

The lock status for individual teams can be changed via the internal API (PUT /i/teams/:tid/features/fileSharing/(un)?locked).

The feature status for individual teams can be changed via the public API (if the feature is unlocked).

Prevent Adminless Groups

The preventAdminlessGroups feature flag controls what happens when a team conversation would otherwise end up without an admin. It is disabled by default and unlocked by default, so team admins can toggle it on or off unless a site administrator locks it.

If you want a different backend-wide default, use the following syntax:

1
2
3
4
5
6
7
8
9
# galley.yaml
preventAdminlessGroups:
  defaults:
    status: disabled|enabled
    lockStatus: locked|unlocked
    config:
      promotionStrategy: alphabetical|random|all
      deletionTimeoutDuration: 7d
      reminderTimeoutDurations: [2d, 4d, 6d]

The settings mean:

  • promotionStrategy: how the backend chooses which eligible members to promote when the last admin leaves and autopromotion is possible.
  • deletionTimeoutDuration: how long to keep an adminless conversation before it is deleted.
  • reminderTimeoutDurations: when before deletion reminder notifications should be sent.

In federated conversations, automatic senderless deletion is skipped when the conversation contains remote members because the corresponding system delete event cannot yet be sent safely to the remote backend. This applies both when the feature is enabled and existing conversations are scanned without an origin user, and when a previously scheduled senderless deletion job runs. Reminders for a skipped deletion are also skipped because they would be misleading. The skipped deletion is logged at info level.

Autopromotion still runs because the conversation-owning backend stores the authoritative member roles. Remote clients may miss the immediate senderless member-update notification, but a subsequent conversation fetch obtains the current role from the owning backend. Member updates and deletions with an origin user continue to use the existing ordinary federation events and are not skipped.

Durations are strings with a number and a unit suffix. Supported units are us, ms, s, m, h, d, and w. It is not recommended or supported to set these below a day in production environments.

Feature responses, including GET /feature-configs, GET /teams/:tid/features, and GET /teams/:tid/features/preventAdminlessGroups, include the duration fields:

1
2
3
4
5
6
7
8
9
{
  "status": "enabled",
  "lockStatus": "unlocked",
  "config": {
    "promotionStrategy": "alphabetical",
    "deletionTimeoutDuration": "7d",
    "reminderTimeoutDurations": ["2d", "4d", "6d"]
  }
}

From a client's perspective, API versioning works like this:

  • API version V18 and newer should send the duration fields to PUT /teams/:tid/features/preventAdminlessGroups.
  • Feature responses include the duration fields for clients to read.

The lock status for individual teams can be changed via the internal API (PUT /i/teams/:tid/features/preventAdminlessGroups/(un)?locked).

The feature status for individual teams can be changed via the public API if the feature is unlocked.

Require External Email Verification

The external feature name validateSAMLemails is kept for backward compatibility, but it is misleading: the feature applies to email addresses originating from both SCIM and SAML, and it controls ownership verification rather than generic email validation.

The feature only affects email address changes originating from SCIM or SAML. Personal users and team users provisioned through the team management app will always go through email verification.

enabled means "user has authority over email address": if a new user account with an email address is created, the user behind the account will receive a verification email. If they complete the verification flow, they will be able to receive emails about their account, eg., if a new device is associated with the account. If they do not verify their email address, they can still use it to log in.

disabled means "team admin has authority over email address, and by extension over all member accounts": if a user account with an email address is created, the address is auto-activated immediately, without any verification email being sent and without confirmation from the recipient. The user can still receive later account notifications on that address, eg., if a new device is associated with the account.

This feature is enabled by default. To disable it, use the following syntax:

1
2
3
4
# galley.yaml
validateSAMLemails:
  defaults:
    status: disabled

2nd Factor Password Challenge

By default Wire enforces a 2nd factor authentication for certain user operations like e.g. activating an account, changing email or password, or deleting an account. If this feature is enabled, a 2nd factor password challenge will be performed for a set of additional user operations like e.g. for generating SCIM tokens, login, or adding a client.

Usually the default is what you want. If you explicitly want to enable the feature, use the following syntax:

1
2
3
4
5
# galley.yaml
sndFactorPasswordChallenge:
  defaults:
    status: disabled|enabled
    lockStatus: locked|unlocked

MLS

If this feature is enabled then clients that support the MLS feature will allow its user to switch between Proteus and the MLS protocol provided the user is listed in protocolToggleUsers. The default protocol that clients will create new conversations with is specified in defaultProtocol. The supportedProtocols array is an ordered list of protocols which may be used by the client. It is used to determine the protocol to use for 1:1 conversations. It must contain the defaultProtocol.

The defaultCipherSuite and allowedCipherSuites contain the default ciphersuite and the allowed ciphersuites that clients should be using. The numerical values should correspond to the indices (starting at 1) specified here.

If the MLS feature is disabled then clients will use the Proteus protocol with this backend.

The default configuration that applies to all teams that didn’t explicitly change their feature configuration can be given in galley’s featureFlags section in the config file:

# galley.yaml
mls:
  defaults:
    status: enabled
    config:
      protocolToggleUsers: []
      defaultProtocol: mls
      supportedProtocols: [proteus, mls] # must contain defaultProtocol
      allowedCipherSuites: [2]
      defaultCipherSuite: 2
    lockStatus: locked

This default configuration can be overriden on a per-team basis through the feature config API

MLS End-to-End Identity

The MLS end-to-end identity team feature adds an extra level of security and practicability. If turned on, automatic device authentication ensures that team members know they are communicating with people using authenticated devices. Team members get a certificate on all their devices.

When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can “snooze” this login. The duration of this grace period (in seconds) is set in the verificationDuration parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s).

The client enrolls using the Automatic Certificate Management Environment (ACME) protocol RFC 8555. The acmeDiscoveryUrl parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form “https://acme.{backendDomain}/acme/{provisionerName}/discovery”. For example: https://acme.example.com/acme/provisioner1/discovery.

useProxyOnMobile is an optional field. If true, mobile clients should use the CRL proxy. If missing, null or false, mobile clients should not use the CRL proxy.

crlProxy contains the URL to the CRL proxy. (Not that this field is optional in the server config, but mandatory when the team feature is updated via the team feature API.)

# galley.yaml
mlsE2EId:
  defaults:
    status: disabled
    config:
      verificationExpiration: 86400
      acmeDiscoveryUrl: null
      useProxyOnMobile: true
      crlProxy: https://example.com
    lockStatus: unlocked

Key for DPoP access token signing

The key for signing DPoP access tokens has to be configured at path brig.secrets.dpopSigKeyBundle e.g. as follows:

1
2
3
4
5
6
brig:
  secrets:
    dpopSigKeyBundle: |
      -----BEGIN PRIVATE KEY-----
      MIGHAgEAMBMGByqGSM49....
      -----END PRIVATE KEY-----

The corresponding public key has to be known by the ACME server.

The key must be an ECDSA P-256 key and can be created with the following openssl command:

openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256 --out private.pem

To get the public key run:

openssl ec -in private.pem -pubout --out public.pem

Channels

Channels are configured per team (as "team feature".) The configuration defines if the channels feature is activated and who can create or open channels.

The configuration has these fields:

  • lockStatus: locked or unlocked
  • status: enabled or disabled
  • config.allowed_to_create_channels: roles
  • config.allowed_to_open_channels: roles

Possible roles are:

Value Who is that? UX description
everyone Partner (a.k.a. external), Member, Admin, Owner Everyone in the team
team-members Member, Admin, Owner Team members (excluding external)
admins Admin, Owner Only admins

Configuration example (in Galley's config):

settings:
---
featureFlags:
---
channels:
  defaults:
    status: enabled
    lockStatus: unlocked
    config:
      allowed_to_create_channels: everyone
      allowed_to_open_channels: team-members

Default values:

  • lockStatus: locked
  • status: disabled
  • config.allowed_to_create_channels: team-members
  • config.allowed_to_open_channels: team-members

Federation Domain

Regardless of whether a backend wants to enable federation or not, the operator must decide what its domain is going to be. This helps in keeping things simpler across all components of Wire and also enables to turn on federation in the future if required.

For production uses, it is highly recommended that this domain be configured as something that is controlled by the operator(s). The backend or frontend do not need to be available on this domain. As per our current federation design, you must be able to set an SRV record for _wire-server-federator._tcp.<domain>. This record should have entries which lead to the federator.

IMPORTANT Once this option is set, it cannot be changed without breaking experience for all the users which are already using the backend.

This configuration needs to be made in brig, cargohold and galley (note the slighly different spelling of the config options).

1
2
3
# brig.yaml
optSettings:
  setFederationDomain: example.com
1
2
3
# cargohold.yaml
settings:
  federationDomain: example.com
1
2
3
# galley.yaml
settings:
  federationDomain: example.com

Federation allow list

See Configure federation strategy (whom to federate with) in brig (since PR#3260).

Federation TLS Config

When a federator connects with another federator, it does so over HTTPS. There are a few options to configure the CA for this:

  1. useSystemCAStore: Boolean. If set to True it will use the system CA.
  2. remoteCAStore: Maybe Filepath. This config option can be used to specify multiple certificates from either a single file (multiple PEM formatted certificates concatenated) or directory (one certificate per file, file names are hashes from certificate).
  3. clientCertificate: Maybe Filepath. A client certificate to use when connecting to remote federators. If this option is omitted, no client certificate is used. If it is provided, then the clientPrivateKey option (see below) must be provided as well.
  4. clientPrivateKey: Maybe Filepath. The private key corresponding to the clientCertificate option above. It is an error to provide only a private key without the corresponding certificate.

Both the useSystemCAStore and remoteCAStore options can be specified, in which case the stores are concatenated and used for verifying certificates. When useSystemCAStore is set to false and remoteCAStore is not provided, all outbound connections will fail with a TLS error as there will be no CA for verifying the server certificate.

Examples

Federate with anyone, no client certificates, use system CA store to verify server certificates:

1
2
3
4
5
federator:
  optSettings:
    federationStrategy:
      allowAll:
    useSystemCAStore: true

Federate only with server2.example.com, use a client certificate and a specific CA:

1
2
3
4
5
6
7
8
federator:
  optSettings:
    federationStrategy:
      allowedDomains:
        - server2.example.com
    useSystemCAStore: false
    clientCertificate: client.pem
    clientPrivateKey: client-key.pem

Federation protocols

A backend can restrict creation of new federated conversations according to the protocol used (Proteus or MLS). This is controlled by the federationProtocols setting. For example:

1
2
3
galley:
  settings:
    federationProtocols: ["mls"]

will prevent the creation of a Proteus conversation containing federated users, and will prevent federated users from joining a Proteus conversation on this backend. However, existing Proteus conversations with federated users will continue to function, and users of this backend will be allowed to join new and existing Proteus conversations on federated backends.

Outlook calendar integration

This feature setting only applies to the Outlook Calendar extension for Wire. As it is an external service, it should only be configured through this feature flag and otherwise ignored by the backend.

Example default configuration:

1
2
3
4
5
# galley.yaml
outlookCalIntegration:
  defaults:
    status: disabled
    lockStatus: locked

To set the validity duration of conversation guest links set guestLinkTTLSeconds to the desired number of seconds, maximum 1 year, a value ∈ (0, 31536000]. E.g.

1
2
3
4
# galley.yaml
config:
  settings:
    GuestLinkTTLSeconds: 604800

Limited Event Fanout

To maintain compatibility with clients and their versions that do not implement the limited event fanout when a team member is deleted, the limited event fanout flag is used. Its default value disabled means that the old-style full event fanout will take place when a team member is deleted. Set the flag to enabled to send team events only to team owners and administrators.

Example configuration:

1
2
3
4
# galley.yaml
limitedEventFanout:
  defaults:
    status: disabled

Domain Registration

This feature flag is per default disabled and locked. It can be set by ibis via the internal API and it is not meant to be configured via team management.

1
2
3
4
5
# galley.yaml
domainRegistration:
  defaults:
    status: disabled
    lockStatus: locked

Cells

This feature flag enables cells integration in wire-server. By default, it is disabled and locked. When enabled, it allows changing the cells state of a conversation.

# galley.yaml
cells:
  defaults:
    status: disabled
    lockStatus: locked
    config:
      channels:
        enabled: true
        default: enabled
      groups:
        enabled: true
        default: enabled
      one2one:
        enabled: true
        default: enabled
      users:
        externals: true
        guests: false
      collabora:
        enabled: false
      publicLinks:
        enableFiles: true
        enableFolders: true
        enforcePassword: false
        enforceExpirationMax: 0
        enforceExpirationDefault: 0
      storage:
        perFileQuotaBytes: "100000000"
        recycle:
          autoPurgeDays: 30
          disable: false
          allowSkip: false
      metadata:
        namespaces:
          usermetaTags:
            defaultValues: []
            allowFreeValues: true

Cells Internal

Cells configuration is intentionally split: cells is controlled by the team admin, while cellsInternal is set by the site operator/customer support via the internal API only. For cellsInternal, the status and lockStatus fields are required to be set to enabled and unlocked respectively, as enforced by validation logic. Failure to set these values will result in a configuration error. This block holds the backend URL, Collabora edition, and two storage quota settings:

  • totalLimitBytes is the total team data limit. It is optional for backward compatibility with existing records, and any negative value is accepted as unlimited. The API returns -1 for unlimited.
  • perUserQuotaBytes is the per-user quota. Any negative value is accepted as unlimited. The API returns -1 for unlimited.
# galley.yaml
config:
  settings:
    featureFlags:
      cellsInternal:
        defaults:
          status: enabled
          lockStatus: unlocked
          config:
            backend:
              url: https://cells-beta.wire.com
            collabora:
              edition: COOL
            storage:
              totalLimitBytes: "-1"
              perUserQuotaBytes: "-1"

Allowed Global Operations

allowedGlobalOperations currently supports a single value mlsConversationReset which determines if it is allowed to reset MLS conversations by the client.

This setting cannot be updated in the DB and therefore does not apply to any specific team but instead to the entire backend.

To configure this setting set or update it in the values.yaml of galley as follows:

1
2
3
4
5
6
7
8
# galley.yaml
config:
  settings:
    featureFlags:
      allowedGlobalOperations:
        status: enabled
        config:
          mlsConversationReset: true

Asset Audit Log

Feature toggle for assetAuditLog.

This flag has no configuration payload and is always locked. It cannot be changed via public or internal feature endpoints and is not stored in the DB. It only applies globally and can only be toggled via Helm values.yaml for Galley.

Example configuration:

1
2
3
4
5
6
# galley.yaml
config:
  settings:
    featureFlags:
      assetAuditLog:
        status: enabled

Chat Bubbles

Feature toggle for chatBubbles.

Example default configuration:

1
2
3
4
5
6
7
8
# galley.yaml
config:
  settings:
    featureFlags:
      chatBubbles:
        defaults:
          status: disabled
          lockStatus: locked

Apps

Feature toggle for apps.

Example default configuration:

1
2
3
4
5
6
7
8
# galley.yaml
config:
  settings:
    featureFlags:
      apps:
        defaults:
          status: disabled
          lockStatus: locked

Simplified User Connection Request QR-Code

This feature flag is only used by clients. It does not change any behaviour of the backend itself. If it is enabled (the default) some clients render a QR code in the profile pages of a user to simplify issuing connection requests.

Example configuration which reflects the default:

1
2
3
4
5
6
7
8
# galley.yaml
config:
  settings:
    featureFlags:
      simplifiedUserConnectionRequestQRCode:
        defaults:
          status: enabled
          lockStatus: unlocked

Settings in brig

Some features (as of the time of writing this: only conferenceCalling) allow to set defaults for personal accounts in brig. Those are taken into account in galley’s end-points GET /feature-configs*.

To be specific:

Conference Calling

Two values can be configured for personal accounts: a default for when the user record contains null as feature config, and default that should be inserted into the user record when creating new users:

1
2
3
4
5
6
7
8
# [brig.yaml]
settings:
  setFeatureFlags:
    conferenceCalling:
      defaultForNew:
        status: disabled
      defaultForNull:
        status: enabled

You can omit the entire conferenceCalling block, but not parts of it. Built-in defaults: defaultForNew: null (user record attribute is left empty); defaultForNull: enabled. This maintains behavior prior to the introduction of this change, while allowing site owners to postpone the decision about the default setting.

When new users are created, their config will be initialized with what’s in defaultForNew.

When a null value is encountered, it is assumed to be defaultForNull.

(Introduced in https://github.com/wireapp/wire-server/pull/1811.)

SFT configuration

Configuring SFT load balancing can be done in two (mutually exclusive) settings:

  1. Configuring a SRV DNS record based load balancing setting
1
2
3
4
5
6
# [brig.yaml]
sft:
  sftBaseDomain: sft.wire.example.com
  sftSRVServiceName: sft
  sftDiscoveryIntervalSeconds: 10
  sftListLength: 20

or

  1. Configuring a HTTP-based load balancing setting
1
2
3
# [brig.yaml]
settings:
  setSftStaticUrl: https://sft.wire.example.com

This setting assumes that the sft load balancer has been deployed with the sftd helm chart.

Additionally if setSftListAllServers is set to enabled (disabled by default) then the /calls/config/v2 endpoint will include a list of all servers that are load balanced by setSftStaticUrl at field sft_servers_all. This is required to enable calls between federated instances of Wire.

Calls between federated SFT servers can be enabled using the optional boolean multiSFT.enabled. If provided, the field is_federating in the response of /calls/config/v2 will reflect multiSFT.enabled’s value.

1
2
3
# [brig.yaml]
multiSFT:
  enabled: true

Also, the optional object sftToken with its fields ttl and secret define whether an SFT credential would be rendered in the response of /calls/config/v2. The field ttl determines the seconds for the credential to be valid and secret is the path to the secret shared with SFT to create credentials.

Example:

1
2
3
4
5
6
7
8
9
# [brig.yaml]
sft:
  sftBaseDomain: sft.wire.example.com
  sftSRVServiceName: sft
  sftDiscoveryIntervalSeconds: 10
  sftListLength: 20
  sftToken:
    ttl: 120
    secret: /path/to/secret

coturn native TURN credentials

The optional turn.coturnSecret option (path to the file containing the coturn static-auth-secret) enables the GET /calls/config/v3 endpoint, which serves TURN credentials in coturn's native long-term credential format instead of zauth: username is <unix-expiry>:<uid> and credential is the base64-encoded HMAC-SHA1 of the username keyed with the coturn static-auth-secret. The expiry is driven by turn.tokenTTL. The coturn server must be configured with use-auth-secret, the same static-auth-secret, and a realm. While turn.coturnSecret is unset, /calls/config/v3 responds with 500. Enabling turn.coturnSecret requires the entire advertised TURN fleet (i.e. the servers listed via turn.serversSource) to validate coturn native long-term credentials; turn.secret (zauth) remains authoritative for /calls/config and /calls/config/v2 until those endpoints are decommissioned.

1
2
3
# [brig.yaml]
turn:
  coturnSecret: /path/to/coturn-secret

Locale

setDefaultLocale (deprecated / ignored)

The brig server config option setDefaultLocale has been replaced by setDefaultUserLocale and setDefaultTemplateLocale. Both settings are optional and setDefaultTemplateLocale defaults to EN and setDefaultLocale defaults to setDefaultTemplateLocale. If setDefaultLocale was not set or set to EN before this change, nothing needs to be done. If setDefaultLocale was set to any other language other than EN the name of the setting should be changed to setDefaultTemplateLocale.

setDefaultTemplateLocale

This option determines the default locale for email templates. The language of the email communication is determined by the user locale (see above). Only if templates of the the locale of the user do not exist or if user locale is not set the setDefaultTemplateLocale is used as a fallback. If not set the default is EN. This setting should not be changed unless a complete set of templates is available for the given language.

1
2
3
# [brig.yaml]
optSettings:
  setDefaultTemplateLocale: en

setDefaultUserLocale

This option determines which language to use for email communication. It is the default value if none is given in the user profile, or if no user profile exists (eg., if user is being provisioned via SCIM or manual team invitation via the team management app). If not set, setDefaultTemplateLocale is used instead.

1
2
3
# [brig.yaml]
optSettings:
  setDefaultUserLocale: en

MLS settings

setEnableMLS

This option determines whether MLS is supported on this backend. When set to false (or absent), MLS endpoints will fail without performing any action.

setKeyPackageMaximumLifetime

This option specifies the maximum accepted lifetime of a key package from the moment it is uploaded, in seconds. For example, when brig is configured as follows:

1
2
3
# [brig.yaml]
optSettings:
  setKeyPackageMaximumLifetime: 1296000 # 15 days

any key package whose expiry date is set further than 15 days after upload time will be rejected.

Federated domain specific configuration settings

See Configure federation strategy (whom to federate with) in brig (since PR#3260).

API Versioning

OAuth

For more information on OAuth please refer to oauth.

En-/Disable OAuth as follows (if not set the default is disabled):

1
2
3
4
# [brig.yaml]
optSettings:
  # ...
  setOAuthEnabled: [true|false]

JWK

The JSON Web Keys in test/resources/oauth/ are used to sign and verify OAuth access tokens in the local integration tests. The path to the JWK can be configured in brig.integration.yaml as follows:

1
2
3
4
# [brig.yaml]
optSettings:
  # ...
  setOAuthJwkKeyPair: test/resources/oauth/ed25519.jwk

A JWK can be generated with didkit e.g. Run cargo install didkit-cli to install and didkit generate-ed25519-key to generate a JWK.

Expiration time

Optionally, configure the OAuth authorization code, access token, and refresh token expiration time in seconds with the following settings:

1
2
3
4
5
6
# [brig.yaml]
optSettings:
  # ...
  setOAuthAuthCodeExpirationTimeSecs: 300 # 5 minutes
  setOAuthAccessTokenExpirationTimeSecs: 300 # 5 minutes
  setOAuthRefreshTokenExpirationTimeSecs: 14515200 # 24 weeks

For more information on what these settings mean in particular, please refer to oauth.

Max number of active refresh tokens

The maximum number of active OAuth refresh tokens a user is allowed to have. Built-in default:

1
2
3
4
# [brig.yaml]
optSettings:
  # ...
  setOAuthMaxActiveRefreshTokens: 10

Scopes

Which scope an OAuth token needs is configured per route in nginz's Helm chart, and enforced by nginz:

1
2
3
4
5
6
7
8
# [nginz/values.yaml]
nginx_conf:
  upstreams:
    galley:
      - path: /conversations/([^/]*)/code
        envs:
        - all
        oauth_scopes: ["read:conversations_code", "write-only:conversations_code"]

A scope has a tier and a base, e.g. the tier read and the base conversations_code. The method of the request decides which tier it needs:

method tier
GET read
POST, PUT write-only
DELETE delete-only

Only the listed scopes of that tier let a token in, and the tiers are independent of each other: write-only: does not include read:. So in the example above, a GET needs read:conversations_code, and a DELETE gets nowhere, because the list has no delete-only:conversations_code.

A route with no oauth_scopes (or with an empty list) (and with no oauth_scope, see next section) accepts no OAuth token at all. It may still be reachable with a cookie or a zauth token; scopes are about OAuth only.

Every scope should also be named in the swagger docs, which is a separate annotation in the routing tables. A unit test in wire-api compares the two and fails if they disagree.

Deprecated: oauth_scope

Older configurations name only the base and leave the tier to nginz:

# [nginz/values.yaml]
        oauth_scope: conversations_code

Here the tiers build on one another: write: includes read:, and admin: includes write:. A GET therefore passes with read:, write:, or admin:conversations_code, and a DELETE needs admin:conversations_code.

oauth_scopes replaces this, and wins over it where a route has both. Tokens work across the change in either direction: a token with old scopes gets into a route that has moved to oauth_scopes, and a token with new scopes gets into a route that has not.

Password hashing options

Since release 5.6.0, wire-server can hash passwords with argon2id to be stored at rest. If you do not do anything, the deployment will still use scrypt.

The password hashing options are set for brig and galley:

brig:
  optSettings:
    setPasswordHashingOptions:
      algorithm: # argon2id or scrypt
      # These options only apply to argon2id
      iterations: ...
      memory: ... # memory needed in KiB
      parallelism: ...
galley:
  settings:
    passwordHashingOptions:
      algorithm: # argon2id or scrypt
      # These options only apply to argon2id
      iterations: ...
      memory: ... # memory needed in KiB
      parallelism: ...

Performance implications: argon2id typically takes longer and uses more memory than scrypt. So when migrating to it brig and galley pods must be allocated more resouces according to the chosen paramters.

When configured to use argon2id, the DB will be migrated slowly over time as the users enter their passwords (either to login or to do other operations which require explicit password entry). This migration is NOT done in reverse, i.e., if a deployment started with argon2id as the algorithm then chose to move to scrypt, the passwords already stored will not get rehashed automatically, however the users will still be able to use them to login.

NOTE It is highly recommended to move to argon2id as it will be made the only available choice for the algorithm config option in future.

Due to the performance implications, password hashing has to be rate limited more than other operations. To allow this, the rate limiting happens at a deeper level than nginx. It can be configured using these options:

brig:
  optSettings:
    setPasswordHashingRateLimit:
      ipAddrLimit:
        burst: 5
        inverseRate: 300000000 # 5 mins, makes it 12 reqs/hour
      userLimit:
        burst: 5
        inverseRate: 60000000 # 1 min, makes it 60 req/hour
      internalLimit:
        burst: 10
        inverseRate: 0 # No rate limiting for internal use
      ipv4CidrBlock: 32 # Only block individual IP addresses
      ipv6CidrBlock: 64 # Block /64 range at a time.
      ipAddressExceptions: []
      maxRateLimitedKeys: 100000 # Estimated memory usage: 4 MB
galley:
  settings:
    passwordHashingRateLimit:
      ipAddrLimit:
        burst: 5
        inverseRate: 300000000 # 5 mins, makes it 12 reqs/hour
      userLimit:
        burst: 5
        inverseRate: 60000000 # 1 min, makes it 60 req/hour
      internalLimit:
        burst: 10
        inverseRate: 0 # No rate limiting for internal use
      ipv4CidrBlock: 32 # Only block individual IP addresses
      ipv6CidrBlock: 64 # Block /64 range at a time.
      ipAddressExceptions: []
      maxRateLimitedKeys: 100000 # Estimated memory usage: 4 MB

The above are the default values.

The rate limiting happens using the Token Bucket Algorithm. The parameters can be separately configured for: 1. IP Addresses to be used in case of unauthenticated requests using ipAddrLimit. 2. Users and providers using userLimit. 3. Internal usages (like calls from backoffice) using internalLimit.

The ipAddressExceptions have to be CIDR blocks which can be specified like "127.0.0.0/8" to allow any IP address from 127.0.0.0 to 127.255.255.255 to by pass the rate limits. To limit one particular IP address, it can be specified as 127.0.0.1/32.

Group info consistency check

To enable group info consistency checks on MLS commits:

1
2
3
4
# galley.yaml
config:
  settings:
    checkGroupInfo: true

Enabling consistency checks makes Galley parse the ratchet tree extension in the group info of every commit and compare it to the group state after applying the commit.

Disabling API versions

It is possible to disable one ore more API versions. When an API version is disabled it won’t be advertised on the GET /api-version endpoint, neither in the supported, nor in the development section. Requests made to any endpoint of a disabled API version will result in the same error response as a request made to an API version that does not exist.

Each of the services brig, cannon, cargohold, galley, gundeck, proxy, spar should to be configured with the same set of disable API versions in each service’s values.yaml config files.

For example to disable API version v3, you need to configure:

# brig's values.yaml
config.optSettings.setDisabledAPIVersions: [ v3 ]

# cannon's values.yaml
config.disabledAPIVersions: [ v3 ]

# cargohold's values.yaml
config.settings.disabledAPIVersions: [ v3 ]

# galley's values.yaml
config.settings.disabledAPIVersions: [ v3 ]

# gundecks' values.yaml
config.settings.disabledAPIVersions: [ v3 ]

# proxy's values.yaml
config.disabledAPIVersions: [ v3 ]

# spar's values.yaml
config.disabledAPIVersions: [ v3 ]

The development API version(s) can be disabled either explicitly or by adding the development keyword to the list of disabled API versions. E.g.:

config.disabledAPIVersions: [ v3, development ]

This setting is required to be present for all the services (brig, cannon, cargohold, galley, gundeck, proxy, and spar).

The default value (provided under charts/<service>/values.yaml) is [ development ] and disables the development versions. To enable all versions including the development versions set the value to be empty: [].

Disabling federation API versions

Federation API versions are configured independently from public client API versions. The development federation version is disabled by default. Set the value to [] to enable it, or list an explicit version or development to disable it.

When using the unified wire-server chart, configure this once at the chart root:

apiVersions:
  disabledFederationAPIVersions: [ development ]

When deploying service charts separately, configure the corresponding Brig, Galley, and Cargohold service-level settings with the same value.

When disabled, a federation version is omitted from version negotiation and incoming requests using it are rejected. Outgoing federation requests do not negotiate a disabled local version.

Team invitation URL for personal users

To configure the team invitation URL for personal users that is sent vai email, emailSMS.team.tExistingUserInvitationUrl should be set to the desired URL, e.g.:

1
2
3
4
5
brig:
  config:
    emailSMS:
      team:
        tExistingUserInvitationUrl: '{{ .Values.accountUrl }}/accept-invitation/?team-code=${code}'

In some environments the team config section does not exist. In this case brig’s configmap constructs the URL from the account pages URL which then must be set under externalUrls.accountPages e.g. as follows:

1
2
3
4
brig:
  config:
    externalUrls:
      accountPages: https://account.wire.com

Enterprise Login Audit Log

Audit logs for any update (POST, PUT, DELETE) of a domain registration via the internal API are sent via email to the specified email address. If not specified no audit logs will be sent.

1
2
3
4
brig:
  config:
    optSettings:
      setAuditLogEmailRecipient: security@wire.com

Ephemeral User Creation

An ephemeral user is a temporary user that can be created without an email address to join conversations via a guest link. The ephemeral user account exists only for a limited period of time, usually 24h. Ephemeral user creation is enabled per default. To disable the feature set setEphemeralUserCreationEnabled to false:

1
2
3
4
brig:
  config:
    optSettings:
      setEphemeralUserCreationEnabled: false

Nomad Profiles

If the backend supports nomad profiles, set the optional field setNomadProfiles to true.

1
2
3
4
brig:
  config:
    optSettings:
      setNomadProfiles: true

Domain Verification Challenge TTL

1
2
3
4
brig:
  config:
    optSettings:
      setChallengeTTL: <seconds>

setChallengeTTL defines how long a domain verification challenge should be stored. The challenge (StoredDomainVerificationChallenge) will be deleted after this period.

SSO Settings

setSsoIdpChangeDetectionInputs

When the reported ssoIdpChangeDetectionEnabled is true, the authenticated GET /system/settings endpoint tells clients to compare the stored SSO IdP ID with the IdP ID used for the current login and to keep existing locally decrypted messages when they match. When false or absent, clients treat an IdP change as before. Only the authenticated endpoint reports this field; the public /system/settings/unauthorized endpoint does not.

Brig derives ssoIdpChangeDetectionEnabled from these raw spar inputs: it reports true iff multiIngressDomainConfigs and idpCertFingerprintAllowlist are both non-empty. The wire-server Helm chart renders them from spar.config; deployments outside the chart should mirror their spar multi-ingress configuration, and the absence of this setting disables the feature.

1
2
3
4
5
6
7
8
9
# [brig.yaml]
optSettings:
  setSsoIdpChangeDetectionInputs:
    multiIngressDomainConfigs:
      nginz-https.example.com:
        appUri: https://webapp.example.com
        ssoUri: https://nginz-https.example.com/sso
    idpCertFingerprintAllowlist:
      - "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD"

Settings in cargohold

AWS S3 (or an alternative provider / service) is used to upload and download assets. The Haddock of CargoHold.Options.AWSOpts provides a lot of useful information.

  • settings.maxTotalBytes (integer, bytes): maximum upload size for team users.
  • settings.maxTotalBytesStrict (integer, bytes): stricter maximum upload size for non-team users (including ephemeral users).

Settings in cannon

Events websocket inactivity

The /events websocket will close a connection when it detects client inactivity. The inactivity behaviour can be controlled by setting the cannon options wsOpts.activityTimeout and wsOpts.pongTimeout.

After wsOpts.activityTimeout microseconds of client inactivity (including no pings), the server sends a ping, and waits for a corresponding pong for wsOpts.pongTimeout microseconds. If no pong is received within this time window, the connection is terminated.

If not specified, both options default to 30 seconds.

Settings in spar

This section describes the common "single-ingress" (one backend is reachable by only one domain) case. The multi-ingress case is described in Multi-Ingress setup.

SAML

In Helm:

config:
...
  appUri: <webapp-uri> # E.g. https://webapp.<domain>
  ssoUri: <service-provider-uri> # E.g. https://nginz-https.<domain>/sso
  contacts:
    - type: <contact-type> # One of ContactTechnical, ContactSupport, ContactAdministrative, ContactBilling, ContactOther
      company: <company-name> # Optional
      email: <contact-email-address> # Optional
      givenName: <name> # Optional
      surname: <name> # Optional
      phone: <phone-number-string> # Optional
...

appUri and ssoUri are mapped to spAppUri and spSsoUri in the spar configuration respectively.

Provide SSO codes by user email address

This feature is enabled by the enableIdPByEmailDiscovery configuration flag. It relates to the /sso/get-by-email endpoint which can provide SSO codes (IdP IDs) for given email addresses. The idea is to ease the SSO flow at client side (your email address is easier at hand than some UUID).

By default enableIdPByEmailDiscovery is disabled. The /sso/get-by-email constantly responds with HTTP 404 then.

If it is enabled, clients can send a HTTP POST request to /sso/get-by-email with this request body format:

1
2
3
{
  "email": <user-email-address>
}

If there is an SSO code for this user, the response is a HTTP 200:

1
2
3
{
  "sso_code": <idp-id>
}

Otherwise a HTTP 404 is returned:

1
2
3
{
  "sso_code": null
}
SSO code lookup algorithm

Given an email address, the SSO code is looked up by these criteria:

  • The email address must belong to a member of any team.
  • The user must be activated. Either by the activation mail flow or by auto-activation.
  • The mapping must be unambiguous (there must be exactly one matching IdP). In multi-ingress mode, IdPs are always bound to one domain; the request domain must match the IdP's configured domain.
  • The user is a SSO user. So it was created via SCIM with SSO enabled (any IdP configured in SCIM token) OR created via SSO (no SCIM involved).

The last condition ensures that team admins cannot get into locked-out situations due to misconfigured IdPs.

Rate limiting

The /sso/get-by-email endpoint could be used to discover existing email addresses and explore their IdPs by applying brute-force strategies. Thus, it is rate limited as reqs_per_addr_sso_get_by_email zone. Details can be configured in nginz's Helm chart in the nginx_conf.user_rate_limit_request_zones list.

IdP certificate fingerprint allowlist

This feature restricts which X.509 certificates can be used in IdP metadata. When configured, all certificates in IdP descriptors must have a SHA-1 fingerprint present in the allowlist, or IdP creation/update and SAML AuthnResponse (/sso/finalize-login) requests will be rejected.

This limits team admins in their choice of IdPs. E.g. a malicious team admin couldn't provision bad IdPs, as possible IdP certificates are restricted by the allowlist.

For multi-ingress setups, this feature is mandatory to prevent security-relevant configuration mistakes. For regular (non-multi-ingress) setups, it is optional and disabled by default in Helm (the attribute can be left out as well):

1
2
3
4
config:
  spar:
...
    idpCertFingerprintAllowlist: []

Example with certificate fingerprints:

1
2
3
4
5
6
config:
  spar:
...
    idpCertFingerprintAllowlist:
      - "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD"
      - "F4:A2:73:D7:B7:2E:EA:66:E1:CB:81:E9:58:BC:1A:E9:CF:3C:95:C4"

The allowlist is a JSON/YAML array of SHA-1 fingerprints as hex strings. Entries can be in any of these formats (all parse to the same value):

  • Uppercase with colons: AA:BB:CC:DD:...
  • Lowercase with colons: aa:bb:cc:dd:...
  • No separators: AABBCCDD... or aabbccdd...
  • Mixed case with optional colons/whitespace: __AA:bb:CC:dd_

All formats must be exactly 40 hex digits (20 bytes).

Invalid hex or wrong length (anything not exactly 20 bytes / 40 hex digits) causes the configuration to fail at startup with a clear error message.

Danger

This feature enables multi-ingress cross-IdP authentication in multi-ingress scenarios. I.e. multiple IdPs can be used to authenticate the same account. This can be a security issue if IdPs are not configured for this! See Multi-ingress cross-IdP SSO(fallback).

SCIM

In Helm:

1
2
3
4
config:
...
    maxScimTokens: <number-of-allowed-scim-tokens>
    scimBaseUri: <scim-endpoint-uri>

If not defined, scimBaseUri is automatically deduced from ssoUri (see SAML.) E.g. appUri: https://nginz-https.example.com/sso would be translated to scimBaseUri: https://nginz-https.example.com/scim/v2.

Multi-Ingress setup

In a multi-ingress setup the backend is reachable via several domains, each handled by a separate Kubernetes ingress. This is useful to obfuscate the relationship of clients to each other, as an attacker on TCP/IP-level could only see domains and IPs that do not obviously relate to each other. Each of these backend domains represents a virtual backend. N.B. these backend domains are DNS domains only, not to be confused of the “backend domain” term used for federation (see Federation). In single-ingress setups the backend DNS domain and federation backend domain is usually be the same, but this is not true for multi-ingress setups.

For a multi-ingress setup multiple services need to be configured:

Nginz

nginz sets CORS headers. To generate them for multiple domains (usually, nginz works with only one root domain nginx_conf.external_env_domain) these need to be defined with nginx_conf.additional_external_env_domains.

E.g.

1
2
3
4
5
nginx_conf:
  additional_external_env_domains:
    - red.example.com
    - green.example.org
    - blue.example.net

Note:

Cannon

cannon sets CORS headers for direct API accesses by clients. To generate them for multiple domains (usually, cannon works with only one root domain) these need to be defined with nginx_conf.additional_external_env_domains.

E.g.

1
2
3
4
5
nginx_conf:
  additional_external_env_domains:
    - red.example.com
    - green.example.org
    - blue.example.net

Cargohold

The backend domain of a download request is defined by its Z-Host header which is set by nginz. Multi-ingress handling only applies to download requests as these are implemented by redirects to the S3 assets host for local assets. Uploads are handled by cargohold directly itself.

For a multi-ingress setup aws.multiIngress needs to be configured as a map from backend domain (Z-Host header value) to a S3 download endpoint. The Z-Host header is set by nginz to the value of the incoming requests Host header. If there’s no config map entry for a provided Z-Host in a download request for a local asset, then an error is returned. When configured the configuration of s3DownloadEndpoint is ignored.

This example shows a setup with fake backends red, green and blue:

aws:
  # S3 endpoint for internal communication (cargohold -> S3)
  s3Endpoint: http://s3.internal.example

  # This option is ignored when multiIngress is configured
  s3DownloadEndpoint: https://assets.default.example.com

  # Other settings can still be used
  # ...

  # Map from backend domain to S3 download domain
  multiIngress:
    - nginz-https.red.example.com: https://assets.red.example.com
    - nginz-https.blue.example.com: https://assets.blue.example.com
    - nginz-https.green.example.com: https://assets.green.example.com

This sequence diagram illustrates how users on different virtual backends (represented by different Kubernetes ingresses) download local assets according to the configuration example above:

Sequence Diagram: Alice and Bob download an asset

Audit logging

  • settings.assetAuditLogEnabled (boolean): enables asset upload/download audit logging and metadata collection on the backend. Intended for enterprise/on‑prem deployments; should remain disabled on cloud. Default: false.

Galley

For conversation invite links to be correct in a multi-ingress setup settings.multiIngress needs to be configured as map from Z-Host to the conversation URI prefix. This setting is a Z-Host depended version of settings.conversationCodeURI. In fact settings.multiIngress and settings.conversationCodeURI are mutually exclusive.

Example:

1
2
3
multiIngress:
   red.example.com: https://account.red.example.com/conversation-join/
   green.example.com: https://account.green.example.net/conversation-join/

Spar

To enable SSO via SAML spar needs to act as multiple ServiceProviders (SPs); one per multi-ingress domain. The configuration is done with a map of domains to SP settings. It replaces the usual (single-domain) SP configuration.

So, we go from single-domain SP configuration:

config:
...
  appUri: <webapp-uri> # E.g. https://webapp.<domain>
  ssoUri: <service-provider-uri> # E.g. https://nginz-https.<domain>/sso
  contacts:
    - type: <contact-type> # One of ContactTechnical, ContactSupport, ContactAdministrative, ContactBilling, ContactOther
      company: <company-name> # Optional
      email: <contact-email-address> # Optional
      givenName: <name> # Optional
      surname: <name> # Optional
      phone: <phone-number-string> # Optional
...

To the multi-domain SP configuration:

config:
...
  domainConfigs:
    <domain1>: # The domain of the incoming nginz-https host. E.g. nginz-https.<domain>
      appUri: <webapp-uri> # E.g. https://webapp.<domain>
      ssoUri: <service-provider-uri> # E.g. https://nginz-https.<domain>/sso
      contacts:
        - type: <contact-type> # One of ContactTechnical, ContactSupport, ContactAdministrative, ContactBilling, ContactOther
          company: <company-name> # Optional
          email: <contact-email-address> # Optional
          givenName: <name> # Optional
          surname: <name> # Optional
          phone: <phone-number-string> # Optional
    <domain2>:
      ...
(N.B. the structures in Helm values and spar's service YAML file are almost the same. Only the outer config object is added to the spar YAML config in Helm.)

The inner data structure stays the same. The difference is that it's on config's top level for single-ingress and in each <domain> entry in domainConfigs for multi-ingress.

In a single-ingress setup the SCIM base URI can be deduced from ssoUri in Helm (see SAML.) In a multi-ingress setup this relationship isn't that clear as there are multiple ssoUris defined. So, the SCIM base URI needs to be set explicitly in scimBaseUri. In spar's YAML config file scimBaseUri is always required.

SAML IdPs

The multi-ingress configuration also affects SAML IdPs: The multi-ingress domain (as specified by the internal Z-Host header; according to the domain the /identity-providers endpoints are accessed on) is stored in the IdP's configuration data. It can be observed as field domain in responses of /identity-providers.

For example:

{
    "extraInfo": {
        "apiVersion": "WireIdPAPIV2",
        "domain": "nginz-https.ernie.example.com",
        "handle": "IdP 1",
        "oldIssuers": [
            "https://issuer.net/_c4590f08-14da-446b-89d0-fcb46ac8ccf9"
        ],
        "replacedBy": null,
        "team": "ce2c2ade-8b93-4db3-b1d3-44ce1d987ca6"
    },
    "id": "ba6afb01-3edf-416c-8561-42e7ecc9b00a",
    "metadata": {
        "certAuthnResponse": [
            "<ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:X509Data><ds:X509Certificate>MIIBOTCBxKADAgECAg4TIFmNatMeqaAE8BWQBTANBgkqhkiG9w0BAQsFADAAMB4XDTI1MDkxODE2MjY1NVoXDTQ1MDkxMzE2MjY1NVowADB6MA0GCSqGSIb3DQEBAQUAA2kAMGYCYQC/KgI1kw9+dXc/XUQ8Q6no9GsT9gX1g3sekVEI7UuxrcHd+Tapzi1T99TdnBDedXCAxGTW6Rwhu3F20j0iAi0neWzi5xv+1KWxK0djzJ0Kxk5AcdDx/Tz+t1Uzd4VXkhECAREwDQYJKoZIhvcNAQELBQADYQAsFrbuDmGZphl9d9VdHyh8a9lIFh3oO5et+tPqFTTRPbbfEewqvtwFWvP9Gf1qgjk0qwKX3GDsFejQf4h94qU1Zf0IE8J/WyIiwEWRvZgAQ9UmqKljmbHKssyNwsl6tTY=</ds:X509Certificate></ds:X509Data></ds:KeyInfo>"
        ],
        "issuer": "https://issuer.net/_2ab62f21-44c0-4c60-a115-d05b5129141d",
        "requestURI": "https://requri.net/22169147-7d84-4991-9652-d7434986b7d8"
    }
}

There can be at most one IdP per multi-ingress domain and team. Creating more returns an error. Though, IdPs can be reconfigured as long as this invariant holds.

Putting it differently: We require an unambiguous mapping (team, domain) -> IdP.

For multi-ingress setups, the idpCertFingerprintAllowlist must be configured to restrict which X.509 certificates can be used in IdP metadata. This restriction was introduced to mitigate risks of Multi-ingress cross-IdP SSO (fallback).

Multi-ingress cross-IdP SSO (fallback)

Terms used below:

  • Authenticating IdP — the external identity provider that issued the SAML assertion, identified by the Issuer URI inside it.
  • IdP configuration — backend's IdP representation registered via /identity-providers, storing the issuer URI, the associated multi-ingress domain, and the team.

In the normal SSO flow spar looks up the authenticating user by their (issuer, NameID) pair — matching the assertion's issuer against the IdP configuration the user was provisioned under.

In a multi-ingress setup each domain has its own IdP configuration with its own issuer URI. A user provisioned under domain A has their SSO identity tied to issuer A's URI. When that user later authenticates via domain B, the IdP authentication response's assertion carries issuer B's URI, so the primary (issuer, NameID) lookup finds nothing. Two IdPs can't have the same Issuer ID because those must be globally unique, and each external identity provider controls its own issuer URI (spar can't override it).

When this primary lookup finds no user, spar therefore attempts a cross-IdP migration when multi-ingress is configured:

  1. NameID must be an email address. Username-based NameIDs are rejected to avoid ambiguity across authenticating IdPs.
  2. The matching IdP configuration is resolved. Spar looks for an IdP configuration in the team whose issuer URI and configured domain both match the assertion's issuer and the incoming Z-Host header (exact match). If this condition is unmet, the login is rejected.
  3. Team-wide user search. The authenticating IdP is now known (step 2), but the user may still be registered under a different team IdP from an earlier login. Spar therefore searches every IdP configuration in the team, pairing each one's issuer with the assertion's email NameID, and tries the primary (issuer, NameID) lookup for each pairing until one matches.
  4. Migrate or provision:
  5. Exactly one match found: The user's SSO identity is updated to point to the IdP configuration for the authenticating IdP's issuer, so subsequent logins hit the primary lookup directly. This saves the complexity of the IdP configuration lookup and keeps the backend's representations of the user's SSO data sound.
  6. No match found: A new user account is auto-provisioned under the authenticating IdP's configuration.
  7. No matching IdP configuration can be resolved: Login is rejected.
Security considerations

It must be ensured that email NameIDs are unique across IdPs by IdP administrators. Otherwise, users may be logged into other users' accounts!

Danger

This fallback feature breaks the 1:1 relationship between users and IdPs. To use it safely, all user accounts must be consistent across all IdPs. Also note, that this increases the attack surface as you have to secure and maintain multiple IdPs; while a successful attack on one of them breaks security for all accounts of a team with that IdP configured.

If in doubt, please contact customer support!

Web apps

These are not maintained by the backend team (which maintains this page), so look at the values.yaml files of the Helm charts for authoritative answers:

Webapp (wire-webapp)

The webapp runs its own web server (a NodeJS server) to serve static files and the webapp config (based on environment variables). In a multi-ingress configuration, a single webapp instance will be deployed and be accessible from multiple domains (say webapp.red.example.com and webapp.green.example.com). When the webapp is loaded from one of those domains it first does a request to the web server to get the config (that will give it, for example, the backend endpoint that it should hit).

Because of the single instance nature of the webapp, by default the configuration is static and the root url to the backend API can be set there (say nginz-https.root.example.com). In order to completely hide this root domain to the webapp, an environment variable can be set to allow the webapp hostname to be used to generate the API endpoint, team settings links, account page links and CSP headers.

The “hostname” is the result of the domain name minus the webapp. part of it. So querying the webapp on webapp.red.example.com will resolve to red.example.com.

Dynamic hostname replacement is enabled by this variable:

ENABLE_DYNAMIC_HOSTNAME="true"

Then, any other variable that will contain the string [[hostname]] will be replaced by the hostname of the running webapp. (eg. if a webapp is running on webapp.red.example.com then any occurrence of [[hostname]] in the config will be replaced by red.example.com).

You may use the template variable [[hostname]] in any environment variable to not provide (reveal) actual domain names.

For example, as Helm chart values:

config:
  externalUrls:
    backendRest: "nginz-https.[[hostname]]"
    backendWebsocket: "nginz-ssl.[[hostname]]"
    appHost: "webapp.[[hostname]]"
envVars:
  ENABLE_DYNAMIC_HOSTNAME: "true"
  URL_ACCOUNT_BASE: "https://account.[[hostname]]"
  URL_TEAMS_BASE: "https://teams.[[hostname]]"
  CSP_EXTRA_CONNECT_SRC: "https://*.[[hostname]], wss://*.[[hostname]]"
  CSP_EXTRA_DEFAULT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_FONT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_FRAME_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_IMG_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_MANIFEST_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_MEDIA_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_OBJECT_SRC: ""
  CSP_EXTRA_SCRIPT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_STYLE_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_WORKER_SRC: "https://*.[[hostname]]"
  FEATURE_ENABLE_FEDERATION: "false"

FEATURE_ENABLE_FEDERATION must be false under multi-ingress. config.externalUrls values are bare hosts — the chart prepends the https:///wss:// scheme itself.

CSP_EXTRA_OBJECT_SRC could be defined with a dynamic host as well, but app was tested with the "" (empty string) value which will render to object-src 'none';.

Please note that this example is not a complete configuration and shows only multi-ingress related entries.

Account-pages (wire-account)

The account-pages app is similar to the webapp in terms of technologies and multi-ingress setup. So, make sure to read the section about the webapp's configuration first. The hostname is the domain minus the account. part of it (analogous to webapp's webapp. stripping). The corresponding configuration options are:

config:
  externalUrls:
    backendRest: "nginz-https.[[hostname]]"
    appHost: "account.[[hostname]]"
envVars:
  ENABLE_DYNAMIC_HOSTNAME: "true"
  URL_TEAMS_BASE: "https://teams.[[hostname]]"
  URL_WEBAPP_BASE: "https://webapp.[[hostname]]"
  CSP_EXTRA_CONNECT_SRC: "https://*.[[hostname]], wss://*.[[hostname]]"
  CSP_EXTRA_IMG_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_SCRIPT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_DEFAULT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_FONT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_FRAME_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_MANIFEST_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_OBJECT_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_MEDIA_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_STYLE_SRC: "https://*.[[hostname]]"
  CSP_EXTRA_WORKER_SRC: "https://*.[[hostname]]"

Please note that this example is not a complete configuration and shows only multi-ingress related entries.

Team-settings (wire-team-settings)

There are no multi-ingress specific configuration options for team-settings. As ENABLE_DYNAMIC_HOSTNAME hasn't been implemented for the team-settings app, yet, it works only on the main domain.

TLS-encrypted Cassandra connections

By default, all connections to Cassandra by the Wire backend are unencrypted. To configure client-side TLS-encrypted connections (where the Wire backend is the client), a Certificate Authority in PEM format needs to be configured.

The ways differ regarding the kind of program:

  • Services expect a cassandra.tlsCa: <filepath> attribute in their config file.
  • *-schema CLI commands accept a --tls-ca-certificate-file <filepath> parameter.
  • brig-index migrate-data accepts a --cassandra-ca-cert <filepath> parameter.

When a CA PEM file is configured, all Cassandra connections are opened with TLS encryption i.e. there is no fallback to unencrypted connections. This ensures that connections that are expected to be secure, would not silently and unnoticed be insecure.

In Helm charts, the CA PEM is either provided as multiline string in the cassandra.tlsCa attribute or as a reference to a Secret in cassandra.tlsCaSecretRef.name and cassandra.tlsCaSecretRef.key. The name is the name of the Secret, the key is the entry in it. Such a Secret can e.g. be created by cert-manager.

The CA may be self-signed. It is used to validate the certificate of the Cassandra server.

How to configure Cassandra to accept TLS-encrypted connections in general is beyond the scope of this document. The k8ssandra-test-cluster Helm chart provides an example how to do this for the Kubernetes solution K8ssandra. In the example cert-manager generates a Certificate including Java KeyStores, then trust-manager creates synchronized Secrets to make only the CA PEM accessible to services (and not the private key.)

The corresponding Cassandra options are described in Cassandra’s documentation: client_encryption_options

Configure Elasticsearch basic authentication

When the Wire backend is configured to work against a custom Elasticsearch instance, it may be desired to enable basic authentication for the internal communication between the Wire backend and the ES instance. To do so the Elasticsearch credentials can be set in wire-server’s secrets for brig and elasticsearch-index as follows:

brig:
  secrets:
    elasticsearch:
      username: elastic
      password: changeme

elasticsearch-index:
  secrets:
    elasticsearch:
      username: elastic
      password: changeme

In some cases an additional Elasticsearch instance is needed (e.g. for index migrations). To configure credentials for the additional ES instance add the secret as follows:

1
2
3
4
5
brig:
  secrets:
    elasticsearchAdditional:
      username: elastic
      password: changeme

Configure TLS for Elasticsearch

If the elasticsearch instance requires TLS, it can be configured like this:

1
2
3
4
5
6
7
8
brig:
  config:
    elasticsearch:
      scheme: https

elasticsearch-index:
  elasticsearch:
    scheme: https

In case a custom CA certificate is required it can be provided like this:

1
2
3
4
5
6
7
brig:
  config:
    elasticsearch:
      tlsCa: <PEM encoded CA certificates>
elasticsearch-index:
  elasticsearch:
    tlsCa: <PEM encoded CA certificates>

There is another way to provide this, in case there already exists a kubernetes secret containing the CA certificate(s):

brig:
  config:
    elasticsearch:
      tlsCaSecretRef:
        name: <Name of the secret>
        key: <Key in the secret containing pem encoded CA Cert>
elasticsearch-index:
  elasticsearch:
    tlsCaSecretRef:
      name: <Name of the secret>
      key: <Key in the secret containing pem encoded CA Cert>

For configuring addtionalWriteIndex in brig (this is required during a migration from one index to another or one ES instance to another), the settings need to be like this:

1
2
3
4
5
6
7
brig:
  config:
    elasticsearch:
      additionalWriteScheme: https
      # One or none of these:
      # addtionalTlsCa: <similar to tlsCa>
      # addtionalTlsCaSecretRef: <similar to tlsCaSecretRef>

WARNING: Please do this only if you know what you’re doing.

In case it is not possible to verify TLS certificate of the elasticsearch server, it can be turned off without tuning off TLS like this:

1
2
3
4
5
6
7
8
brig:
  config:
    elasticsearch:
      insecureSkipVerifyTls: true
      addtionalInsecureSkipVerifyTls: true # only required when addtional index is being used.
elasticsearch-index:
  elasticsearch:
    insecureSkipVerifyTls: true

Configure Redis authentication

If the redis used needs authentication with either username and password or just password (legacy auth), it can be configured like this:

1
2
3
4
gundeck:
  secrets:
    redisUsername: <username>
    redisPassword: <password>

NOTE: When using redis < 6, the redisUsername must not be set at all (not even set to null or empty string, the key must be absent from the config). When using redis >= 6 and using legacy auth, the redisUsername must either be not set at all or set to "default".

While doing migrations to another redis instance, the credentials for the addtional redis can be set as follows:

1
2
3
4
gundeck:
  secrets:
    redisAdditionalWriteUsername: <username>  # Do not set this at all when using legacy auth
    redisAdditionalWritePassword: <password>

NOTE: redisAddtiionalWriteUsername follows same restrictions as redisUsername when using legacy auth.

Configure TLS for Redis

If the redis instance requires TLS, it can be configured like this:

1
2
3
4
gundeck:
  config:
    redis:
      enableTls: true

In case a custom CA certificate is required it can be provided like this:

1
2
3
4
gundeck:
  config:
    redis:
      tlsCa: <PEM encoded CA certificates>

There is another way to provide this, in case there already exists a kubernetes secret containing the CA certificate(s):

1
2
3
4
5
6
gundeck:
  config:
    redis:
      tlsCaSecretRef:
        name: <Name of the secret>
        key: <Key in the secret containing pem encoded CA Cert>

For configuring redisAdditionalWrite in gundeck (this is required during a migration from one redis instance to another), the settings need to be like this:

1
2
3
4
5
6
7
gundeck:
  config:
    redisAdditionalWrite:
      enableTls: true
      # One or none of these:
      # tlsCa: <similar to tlsCa>
      # tlsCaSecretRef: <similar to tlsCaSecretRef>

WARNING: Please do this only if you know what you’re doing.

In case it is not possible to verify TLS certificate of the redis server, it can be turned off without tuning off TLS like this:

1
2
3
4
5
6
gundeck:
  config:
    redis:
      insecureSkipVerifyTls: true
    redisAdditionalWrite:
      insecureSkipVerifyTls: true

Configure RabbitMQ

RabbitMQ authentication must be configured on brig, galley and background-worker. For example:

1
2
3
4
5
6
rabbitmq:
  host: localhost
  port: 5672
  vHost: /
  adminHost: localhost
  adminPort: 15672 # for background-worker

the adminHost and adminPort settings are only needed by background-worker.

In order to enable TLS when connecting to RabbitMQ, the following settings need to be added:

1
2
3
4
rabbitmq:
  enableTls: true
  caCert: test/resources/rabbitmq-ca.pem
  insecureSkipVerifyTls: false

WARNING: Please do this only if you know what you’re doing.

In case it is not possible to verify the TLS certificate of the RabbitMQ server, verification can be turned off by settings insecureSkipVerifyTls to true.

Configure PostgreSQL

brig, galley, and background-worker require a PostgreSQL database. The configured user needs to be able to write data and change the schema (e.g. create and alter tables.)

The internal configuration YAML file format and the Helm charts for brig and galley differ a bit.

Helm chart values.yaml for both:

config:
  postgresql:
    host: postgresql # DNS name without protocol
    port: "5432"
    user: wire-server
    dbname: wire-server
  postgresqlPool:
    # Pool size.
    size: 100

    # Connection acquisition timeout.
    acquisitionTimeout: 10s

    # Maximal connection idle time.
    idlenessTimeout: 10m
secrets:
  pgPassword: user-password # plain text; i.e. not base64 encoded

Internal YAML file:

postgresql:
  port: "5432"
  user: wire-server
  dbname: wire-server
  password: db-password # plain text passwords should only be used in test setups
postgresqlPool:
  size: 100
  acquisitionTimeout: 10s
  idlenessTimeout: 10m
postgresqlPassword: /path/to/pgPassword # refers to a PostgreSQL password file

The brig and galley helm charts also offer an option to mount files (e.g. certificates) into the container by defining additionalVolumeMounts and additionalVolumes. This way does not work for password files (parameter passfile), because libpq-connect requires access rights (mask 0600) for them that we cannot provide for random uids (brig is executed as user brig with a random uid.)

The host can be any DNS name pointing to the database server; e.g. database.example.com would be fine. The port needs to be a number provided as string.

Besides the password file (postgresqlPassword), the fields correspond to libpq-connect parameters. The postgresqlPassword file is read by brig, galley, and background-worker. Its content is used as password field.

Using PostgreSQL for storing Cassandra-backed data

New Installations

For new installations, configure galley.config.postgresMigration to use PostgreSQL for migrated Cassandra-backed data. In the Helm charts, this is the single source of truth and is consumed by galley, brig, and background-worker:

galley:
  config:
    postgresMigration:
      conversation: postgresql
      conversationCodes: postgresql
      teamFeatures: postgresql
      domainRegistration: postgresql
      user: postgresql
      blockList: postgresql
      passwordReset: postgresql
background-worker:
  config:
    migrateConversations: false
    migrateConversationCodes: false
    migrateTeamFeatures: false
    migrateDomainRegistration: false
    migrateBlockList: false
    migratePasswordReset: false

Migration for existing installations

Existing installations should migrate Cassandra-backed data to PostgreSQL over time. For conversations, this is necessary for channel search and management of channels from the team-management UI. It is highly recommended to take a backup of the affected Cassandra data before triggering a migration.

The background-worker.config.migrationOptions.timeout defaults to 5s. It sets an upper bound for a single migration attempt after it has acquired the migration lock, so a stuck item does not keep the migration run blocked indefinitely.

Although migrationOptions is a shared background-worker setting, the migrations run sequentially. That means the same configured timeout can be used as a practical per-migration limit, because only one migration type is executing at a time in the worker.

Migrations are independent and can be run separately, in batches, or all at once. This is expected, because migrations will be released over time. The pattern below applies per postgresMigration setting. A single setting may cover multiple Cassandra tables, depending on the store.

The current settings and their background-worker flags are:

  • conversation -> migrateConversations
  • conversationCodes -> migrateConversationCodes
  • teamFeatures -> migrateTeamFeatures
  • domainRegistration -> migrateDomainRegistration
  • user -> migrateUsers
  • blockList -> migrateBlockList
  • passwordReset -> migratePasswordReset

Migration pattern per migration setting

  1. Prepare the selected migration setting(s) for migration by setting postgresMigration.<setting> to migration-to-postgresql. This enables the migration interpreter for that setting, which ensures data is written to PostgreSQL (store-specific details are handled internally). In the Helm charts, configure this only under galley.config.postgresMigration. brig and background-worker consume the same settings from there, so the migration configuration remains consistent across services.
galley:
  config:
    postgresMigration:
      conversation: migration-to-postgresql
      conversationCodes: migration-to-postgresql
      teamFeatures: migration-to-postgresql
      domainRegistration: migration-to-postgresql
      user: migration-to-postgresql
      blockList: cassandra
      passwordReset: cassandra
background-worker:
  config:
    migrateConversations: false
    migrateConversationCodes: false
    migrateTeamFeatures: false
    migrateDomainRegistration: false
    migrateUsers: false
    migrateBlockList: false
    migratePasswordReset: false

This change should restart the affected pods, and new writes will follow the migration interpreter.

  1. Run the backfill for the selected migration setting(s) via background-worker.
1
2
3
4
5
6
7
8
9
background-worker:
  config:
    migrateConversations: true
    migrateConversationCodes: true
    migrateTeamFeatures: true
    migrateDomainRegistration: true
    migrateUsers: true
    migrateBlockList: true
    migratePasswordReset: true

During migration, Cassandra rows are not deleted. Writes and migration share per-row locks to avoid races, so there is no need to delete early. Deletion is deferred to keep rollback options and to remove Cassandra only after a full cutover to PostgreSQL-only.

Wait for the setting-specific migration metrics to reach 1.0. Metric names are store-specific. Current examples are:

  • conversation: wire_local_convs_migration_finished and wire_user_remote_convs_migration_finished
  • conversationCodes: wire_conv_codes_migration_finished
  • teamFeatures: wire_team_features_migration_finished
  • domainRegistration: wire_domain_registration_migration_finished
  • user: wire_user_migration_finished

⚠️ For user migrations please watch the logs for Invalid user found, skipping. This would be accompanied by an error which is either UserHasNoName or UserHasNoActivated. These users are invalid and all interactions with them were resulting in errors. If these warnings are ignored, these users will stop existing in the system. If these users are to be saved, the operator must insert some value as name and/or activated and then re-trigger the migration after the background worker finishes migrating the valid users. - blockList: wire_block_list_migration_finished - passwordReset: wire_password_reset_migration_finished

  1. Cut over reads and writes to PostgreSQL for the selected migration setting(s). This configuration must be used from now on for every new release.
galley:
  config:
    postgresMigration:
      conversation: postgresql
      conversationCodes: postgresql
      teamFeatures: postgresql
      domainRegistration: postgresql
      user: postgresql
      blockList: cassandra
      passwordReset: postgresql
background-worker:
  config:
    migrateConversations: false
    migrateConversationCodes: false
    migrateTeamFeatures: false
    migrateDomainRegistration: false
    migrateUsers: false
    migrateBlockList: false
    migratePasswordReset: false

How to run migrations independently or in batches

  • To migrate a single setting, set only that setting’s postgresMigration.<setting> and matching migrate<...> flag; leave others unchanged.
  • To migrate a batch, set multiple settings to migration-to-postgresql and enable only the matching migrate<...> flags together.
  • To reduce load, run large stores alone and group small stores together.
  • Some settings cover multiple Cassandra tables. For example, postgresMigration.domainRegistration covers domain_registration, domain_registration_by_team, and domain_registration_challenge.

Configure Cells

If Cells integration is enabled, gundeck must be configured with the name of the RabbitMQ queue where Cells events can be forwarded. This can be done via the cellsEventQueue setting:

1
2
3
gundeck:
  settings:
    cellsEventQueue: "cells_events"

Configure consumable notifications

The consumableNotifications flag controls whether the RabbitMQ-backed Events API for clients with the consumable-notifications capability is operational. When disabled, the legacy notification flow remains active.

This is a root-level Helm values.yaml setting. It is rendered into both the brig and gundeck service configs:

consumableNotifications: false
  • In brig, it is rendered as optSettings.setConsumableNotifications.
  • In gundeck, it is rendered as settings.consumableNotifications.

Background worker: Background jobs

The background worker processes asynchronous jobs (conversation migrations, backend notifications). Configuration is supplied via Helm under background-worker.config and rendered into background-worker.yaml.

Minimal Helm values (under background-worker.config):

# RabbitMQ connection
rabbitmq:
  host: rabbitmq
  port: 5672
  vHost: /
  enableTls: false
  insecureSkipVerifyTls: false
  # tlsCaSecretRef:
  #   name: <secret-name>
  #   key: <ca-attribute>
  # When federation is enabled, background-worker also uses the admin API
  # adminHost defaults to `host` if omitted
  # adminPort: 15672

# Cassandra clusters used by background-worker
cassandra:
  host: <gundeck-cassandra-host>
cassandraBrig:
  host: <brig-cassandra-host>
cassandraGalley:
  host: <galley-cassandra-host>

# PostgreSQL (conversation/user-group stores)
postgresql:
  host: postgresql
  port: "5432"
  user: wire-server
  dbname: wire-server

postgresqlPool:
  size: 5
  acquisitionTimeout: 10s
  idlenessTimeout: 10m

# Start migration workers when true
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateBlockList: false
migratePasswordReset: false

# migration settings
migrationOptions:
  pageSize: 10000
  parallelism: 2
  # Required migration timeout for a single migration attempt.
  timeout: 5s

# Background jobs consumer
backgroundJobs:
  concurrency: 8     # in-flight jobs per process
  jobTimeout: 60s    # per attempt
  maxAttempts: 3     # total attempts incl. first run

# Jobs
jobs:
  pollInterval: 5s   # how often due jobs are discovered
  workerThreads: 1   # worker threads for each job queue
  visibilityTimeout: 60s       # how long a claimed job stays invisible
  jobHeartbeatInterval: 30s    # refresh interval for running jobs
  workerHeartbeatInterval: 10s # refresh interval for worker liveness
  backoffBase: 2.0             # exponential retry backoff base
  backoffCap: 86400s           # maximum exponential retry backoff
  jitter: equal                # none, full, or equal retry jitter
  gracefulShutdownTimeout: 30s # maximum shutdown grace period
  reaperInterval: 300s         # Arbiter reaper interval
  reaperTimeout: 300s          # maximum duration of one reaper pass
  workerStaleThreshold: 300s   # worker heartbeat staleness threshold

Jobs are currently split into two domain queues: `meetings`, which
contains meeting cleanup jobs, and `conversations`, which contains adminless
reminder and deletion jobs. Each queue has its own Arbiter worker pool. The
`workerThreads` value applies independently to both pools.

Both worker pools share the configured `postgresqlPool`; no connection is
reserved permanently for an individual queue or worker pool. An active
transactional job temporarily holds one connection, so concurrent jobs can
borrow multiple connections from the shared pool. Short-lived dispatcher,
heartbeat, and reaper operations also borrow connections as needed. For
example, with two queues and `workerThreads: 3`, up to six active job
transactions may need connections concurrently, in addition to these internal
operations. Size `postgresqlPool.size` and PostgreSQL `max_connections` for
the expected concurrency.

The job runner uses polling rather than LISTEN/NOTIFY. It therefore does not
open a separate listener connection; new jobs are discovered according to
`jobs.pollInterval`.

`backgroundJobs` and `jobs` configure different job systems. The
`backgroundJobs` consumer receives immediate user-group synchronization jobs
from RabbitMQ and controls their in-process concurrency, timeout, and retry
behavior. `jobs` runs Arbiter-backed PostgreSQL jobs that may be
scheduled for a future time, including recurring jobs, and controls their
dispatcher, worker-pool, visibility, retry, and reaper behavior. The systems
are separate because they currently use different transports and execution
semantics. They could be merged in the future if the user-group jobs are
migrated to Arbiter.

# Required for addressing local vs remote backends
federationDomain: example.org

Job runner PostgreSQL connections

Background-worker runs Arbiter jobs through its configured PostgreSQL pool. Choose the pool size and jobs.workerThreads to provide sufficient capacity for the expected job workload, and size PostgreSQL's max_connections accordingly.

At startup, each service that runs the Arbiter migrations briefly opens a separate PostgreSQL connection to acquire the migration advisory lock. Include this transient connection in the PostgreSQL connection budget and startup headroom. It remains open for the migration and related index setup, then is released and closed after the advisory lock is released.

The migrationOptions.timeout setting limits how long a single migration attempt may run after it has acquired the migration lock. If the timeout is exceeded, that migration attempt is aborted and treated as failed.

Choose a value that is comfortably above the normal time for one conversation migration, but still low enough to catch a genuinely stuck migration in a reasonable time. A good starting point for most deployments is 5s, then adjust based on observed migration durations.

Secrets

  • Set background-worker.secrets.pgPassword to pass the PostgreSQL password. The chart mounts it to /etc/wire/background-worker/secrets/pgPassword and sets postgresqlPassword accordingly.

Notes

  • postgresql values follow libpq keywords; password is sourced via secrets.pgPassword.
  • RabbitMQ admin fields (adminHost, adminPort) are templated only when config.enableFederation is true.
  • In the Helm charts, background-worker reads postgresMigration from galley.config.postgresMigration.
  • The migrate... flags control the corresponding PostgreSQL backfill jobs for the current migration settings; leave them false for new installs and after migration.
  • concurrency, jobTimeout, and maxAttempts control parallelism and retry behavior of the consumer.
  • brig and gundeck endpoints default to in-cluster services; override via background-worker.config.brig and .gundeck if your service DNS/ports differ.
  • jobs controls the Arbiter dispatcher, worker, retry, shutdown, and reaper settings. All fields default to the values shown above.
  • jobs.pollInterval controls how often the background worker wakes up to check for due jobs.
  • jobs.workerThreads controls the number of worker threads in each job queue. The default is 1; increasing it allows jobs in that queue to run in parallel when their group keys permit it.
  • Both job queues share the same PostgreSQL pool. Increasing jobs.workerThreads can increase the number of connections needed when more jobs run concurrently, but it does not create a permanently dedicated connection per thread or queue.
  • The job runner is poll-only and does not require an additional PostgreSQL listener connection.