Setting up Matrix Synapse + Element Call from scratch with podman
Table of Contents
It’s been a while since I first setup a Matrix Synapse server. A few things changed since then: installing via container is now the way to go and also Matrix/Element introduced a new way to make audio and video calls not relying on Jitsi anymore. Making all of this work is a little tricky and most of the instructions I found were related to Docker - but I wanted to install via Podman, which I think is the better choice nowadays. Of course, there a similarities but the little differences do count… So, in case you’d like to set up your own Matrix based chat server, I hope the following will be of help.
I will not go into details about what Matrix/Element is or how to set up a Linux server - if you’ve found your way here, you’d probably know that kind of stuff.
In the end you should have:
- Synapse server running connected to a Postgresql instance
- Matrix Authentication Service (MAS), also connected to a Postgresql instance
- Element Web
- Element Call
- we will use delegation for the synapse server as it will be available at
matrix.example.comwhereas the user names will be@<user>:example.com
Prerequisites
Server (VPS)
In order to start you will of course need a server. I guess any VPS will do, probably 1 CPU and 2 GB of RAM are fine for very small deployments, but with 2 CPUs and 4 GB you’re on the safe side. Also note, that depending on federation and the rooms you’ll join, it will also require some amount of disk space. Having around ~100 GB available as a start you should be fine and still have some buffer. Install any recent Linux distribution, but make sure it provides not too outdated versions of Podman. Any recent Ubuntu LTS release is probably a good choice. Restrict logins as good as possible and also install a firewall (in my example I will use ufw) and fail2ban for hardening purposes.
DNS records
You will also need a domain. Register one wherever you like, for the ease of this documentation I will go with “example.com”. Go to your provider’s DNS settings and create A and AAAA records for example.com pointing to your server’s IPs. Then create some additional CNAMEs, i.e. matrix.example.com, mrtc.example.com, web.example.com and mas.example.com, all pointing to example.com.
example.com: this will be the last part of your usernames, i.e.: @USERNAME:example.commatrix.example.com: this is where synapse will be listening tomrtx.example.com: required for Element Callweb.example.com: Element web clientmas.exmaple.com: Matrix authentication service
Prepare the server
I will assume the server is running Ubuntu or Debian here. If you’ve chosen a different distro, commands will vary. If not indicated otherwise, run commands as root.
Additional packages
First install some packages we will require later:
apt install podman nginx
nginx will be our reverse proxy and basically the only application required for our setup not running via podman.
Certificates
We will use Certbot to get our certificates, so checkout it’s documentation. For Ubuntu and alike:
snap install --classic certbot
ufw allow 80/tcp
ufw allow 443/tcp
certbot certonly --nginx # create certificates for "example.com", "matrix.example.com", "web.example.com", "mas.example.com" and "mrtc.example.com"
Application user
The actual application will be running as unprivileged user, so go ahead and create one. There are different ways to do this, i.e.:
groupadd matrix
useradd -m -d /home/matrix -s /usr/bin/bash -c "Matrix User" -g matrix matrix
The user should not have a password and should be locked! (Check /etc/shadow there should be one - or two - ! at the second column.)
Switch to the new user to see if all is fine, exit again and then run loginctl to make sure our services will be running later even if the user is not logged in:
su - matrix
exit
loginctl enable-linger matrix
For later add the $XDG_RUNTIME_DIR to the user’s .bashrc:
echo "export XDG_RUNTIME_DIR=/run/user/$(id -u matrix)" >> /home/matrix/.bashrc
Software installation
From here on we will use the application user we just created. I will stick to the example user matrix here. So, switch to it now.
As mentioned, we will use Podman to get everything up and running making use of its quadlet feature generating systemd unit files (s. here).
All the containers will require some persistent storage or configuration files. For this purpose create a directory within the home of the application user, i.e.:
mkdir ~/application-stack
Postgresql
Though Postgresql is technically not required for Synapse (for MAS it is!), it’s recommended over SQlite. Most of the examples I found were using version 14, but I’d go with the latest one (as the time of writing it’s version 18). So, we’ll spin up a container once and initialize the required DB files. This requires setting a password. You will most likely not need it in the future, but in any case, note it down somewhere and don’t make it an easy one. We’ll use the application-stack directory to create a subdirectory for our Postgresql database files.
mkdir -m 755 ~/application-stack/postgresql
podman run --rm --name postgres -v /home/matrix/application-stack/postgresql:/var/lib/postgresql -e POSTGRES_PASSWORD="<some_password>" -d docker.io/postgres:18-alpine
This should have created the new directory 18 within ~/application-stack/postgresql and you should see the container is up by executing:
podman ps
While the container is still up, we’ll connect to it and create two users and two databases, one for the synapse server and one for the Matrix authentication service (again: note down the passwords you choose):
podman exec -it postgres psql -U postgres
While in the psql prompt:
CREATE USER matrixdbuser WITH PASSWORD '<another_password_here>';
CREATE DATABASE matrixdb ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' template=template0 OWNER matrixdbuser;
CREATE USER masdbuser WITH PASSWORD '<yet_another_password_here>';
CREATE DATABASE masdb OWNER masdbuser;
\l
\q
\l will list the databases and with \q you’ll quit the psql prompt. Now we can stop the postgresql container again:
podman stop postgres
HINT: For some minimal security gain, you could replace trust by scram-sha-256 in ~/application-stack/postgresql/18/docker/pg_hba.conf (this will require root).
Synapse
Next we’ll prepare the synapse server. Let’s create a new directory to hold its persistent files and generate an initial configuration:
mkdir -m 700 ~/application-stack/synapse
mkdir -m 755 ~/application-stack/synapse/data
podman run -it --rm -v /home/matrix/application-stack/synapse/data:/data -e SYNAPSE_SERVER_NAME=example.com -e SYNAPSE_REPORT_STATS=no docker.io/matrixdotorg/synapse:latest generate
This will generate some config files in /home/matrix/application-stack/synapse/data and we need to edit homeserver.yaml:
server_name:should readexample.comchange/add:
public_baseurl: https://matrix.example.com/remove the current
databaseblock and replace it by:database: name: psycopg2 txn_limit: 10000 args: user: matrixdbuser password: <PASSWORD_YOU'VE CHOSEN ABOVE FOR 'matrixdbuser'> database: matrixdb host: postgresql port: 5432 cp_min: 5 cp_max: 10HINT: the
hostpart of the database configuration will simply be the hostname of our Postgresql container, in this examplepostgresql.for Element call to work add:
max_event_delay_duration: 24h experimental_features: msc3266_enabled: true msc4222_enabled: true msc4140_enabled: true rc_message: per_second: 0.5 burst_count: 30 rc_delayed_event_mgmt: per_second: 1 burst_count: 20for Matrix authentication service we’ll need to add:
matrix_authentication_service: enabled: true endpoint: http://mas:9085/ secret: "<something_secret>"HINT:
maswill be the hostname of our Matrix authentication service container. “something_secret” will be generated later, don’t mind this right now.
As we will be using Matrix authentication service for the user management, I’m not sure if enable_registration: false will still work if you want to disable user registration. In my overall setup it’s still disabled. So, depending on your needs, add this or don’t add this to homeserver.yaml.
At this point we already have a working Matrix server configuration - but without voice/video chat and missing a authentication. So, let’s get the additional components configured!
Livekit
Livekit will be responsible for the voice/video chats and we will need a basic configuration here as well. This time, we have to generate it ourselves. I found a few sources, some of them differed in details, but this is what I ended up with. First, we’ll generate a key + secret:
podman run --rm docker.io/livekit/livekit-server:latest generate-keys
It will return an “API Key” and an “API Secret”. Copy both somewhere, we’ll need them in a minute. Next, create a new directory for the configuration file:
mkdir -m 700 ~/application-stack/livekit
… and create ~/application-stack/livekit/livekit.yaml (replace <API Key>: <API Secret> with the actual values created before - the space between : and <API Secret> is crucial!):
port: 7880
bind_addresses:
- ""
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50200
use_external_ip: true
enable_loopback_candidate: false
turn:
enabled: false
domain: mrtc.example.com
cert_file: ""
key_file: ""
tls_port: 5349
udp_port: 3478
external_tls: true
keys:
<API Key>: <API Secret>
logging:
level: info
JWT
The JWT service will bring calls and a Matrix room together. It requires some variables, which you can either set in the quadlet file (s. below) or - and I’d prefer this - create a dedicated env file:
mkdir -m 700 ~/application-stack/jwt
Now create ~/application-stack/jwt/.env with the following content (you will need the API key and API Secret from before again):
LIVEKIT_URL=wss://mrtc.example.com/livekit/sfu
LIVEKIT_KEY=<API Key>
LIVEKIT_SECRET=<API Secret>
LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com
Element Web
Element Web is optional, but it provides a nice and easy way to access your chat server. As before, create a new directory and add a config file to it:
mkdir -m 700 ~/application-stack/element-web
~/application-stack/element-web/config.json:
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.example.com",
"server_name": "example.com"
},
"m.identity_server": {
"base_url": "https://vector.im"
}
},
"brand": "example.com Chat",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"default_theme": "dark",
"room_directory": {
"servers": ["matrix.example.com"]
},
"features": {
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_element_call_video_rooms": true
},
"setting_defaults": {
"breadcrumbs": true
}
}
If you don’t want integrations, you can of course remove those parts.
Matrix Authentication Service
User authentication and management will be done by Matrix’ authentication service, not by the synapse server itself anymore. For details see here. Again, create a dedicated directory and then start the container to generate a basic configuration file:
mkdir -m 700 ~/application-stack/mas
podman run ghcr.io/element-hq/matrix-authentication-service config generate > /home/matrix//application-stack/mas/config.yaml
Check the content of the file. Change the port to 9085, make sure to change public_base and issuer accordingly, adjust the database uri and the matrix configuration (pointing to your synapse server):
http:
listeners:
- name: web
.
.
.
binds:
- address: '[::]:9085'
public_base: https://mas.example.com/
issuer: https://mas.examnple.com/
.
.
.
database:
uri: postgresql://masdbuser:<PASSWORD_YOU'VE CHOSEN ABOVE FOR 'masdbuser'>@postgresql/masdb
.
.
.
matrix:
kind: synapse
homeserver: example.com
secret: "<something_secret>"
endpoint: http://synapse:8008/
HINT: the host part of the database configuration will simply be the hostname of our Postgresql container, in this example postgresql. Same goes for the synapse container in the matrix part of the configuration which we’ll simply set to synapse.
Make sure to copy the secret “something_secret” to the matrix_authentication_service part of your homeserver.yaml now!
For a full overview of configuration options s. here. (Might be a good idea to configure email.)
We have now completed the initial configuration of all services. The magic happens by creating quadlets :-)
Quadlets
For each of our containers we will now create a quadlet, which will then generate a systemd unit file. If not already present, create some directories:
mkdir -p ~/.config/containers/systemd
chmod 700 .config
Networks
We will use two networks: one internal and one external. As Postgresql does not need to be available out side of our server, it will be in the internal network. The Synapse container and the MAS container will be part of the internal network (so they can connect to the Postgresql DB) and of the external network as they need to be available for our nginx reverse proxy. All other containers will be in the external network only. So, create those two network files:
~/.config/containers/systemd/int_network.network:
[Network]
Label=app=int_network
Internal=true
NetworkName=int_network
~/.config/containers/systemd/ext_network.network:
[Network]
Label=app=ext_network
NetworkName=ext_network
Postgreql quadlet
Create the quadlet for the postgresql container ~/.config/containers/systemd/postgresql.container:
[Unit]
Description=Postgresql Service
After=network-online.target
[Container]
Image=docker.io/postgres:18-alpine
ContainerName=matrix_db
AutoUpdate=registry
Volume=/home/matrix/application-stack/postgresql:/var/lib/postgresql:Z
HealthCmd=CMD pg_isready -U postgres
HealthInterval=10s
HealthRetries=5
HealthTimeout=15s
HostName=postgresql
Network=int_network.network
ShmSize=256mb
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
Make sure HostName corresponds to the database configuration parts for synapse and mas!
Synapse quadlet
The quadlet for the synapse container should look like this ~/.config/containers/systemd/synapse.container:
[Unit]
Description=Synapse
After=network-online.target
Requires=postgresql.service
Requires=mas.service
[Container]
Image=docker.io/matrixdotorg/synapse:latest
ContainerName=synapse
AutoUpdate=registry
Volume=/home/matrix/application-stack/synapse/data:/data:Z
HealthCmd=CMD-SHELL curl -s --noproxy localhost localhost:8008/health | grep -q 'OK' || exit 1
HealthInterval=10s
HealthRetries=5
HealthTimeout=15s
HostName=synapse
Network=ext_network.network
Network=int_network.network
PublishPort=127.0.0.1:8008:8008
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
Also here: make sure HostName matches the endpoint: configuration of Matrix authentication services’ config.yaml.
Livekit
Livekit’s quadlet ~/.config/containers/systemd/livekit.container:
[Unit]
Description=Livekit
After=network-online.target
[Container]
Image=docker.io/livekit/livekit-server:latest
ContainerName=livekit
AutoUpdate=registry
Exec=--config /etc/livekit.yaml
Volume=/home/matrix/application-stack/livekit/livekit.yaml:/etc/livekit.yaml:Z,ro
Network=ext_network.network
AddHost=example.com:host-gateway
AddHost=matrix.example.com:host-gateway
AddHost=mrtc.example.com:host-gateway
PublishPort=127.0.0.1:7880:7880
PublishPort=7881:7881
PublishPort=50000-50200:50000-50200/udp
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
The AddHosts are quite important. I was really struggling with getting Element Call to work and then thankfully found the solution at GNU/Linux.ch.
JWT quadlet
The JWT quadlet ~/.config/containers/systemd/jwt.container:
[Unit]
Description=Element Call
After=network-online.target
Requires=livekit.service
[Container]
Image=ghcr.io/element-hq/lk-jwt-service:latest
ContainerName=jwt
AutoUpdate=registry
EnvironmentFile=/home/matrix/application-stack/jwt/.env
AddHost=example.com:host-gateway
AddHost=matrix.example.com:host-gateway
AddHost=mrtc.example.com:host-gateway
Network=ext_network.network
PublishPort=127.0.0.1:8080:8080
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
Again: mind the AddHosts!
Element web quadlet
The Element web quadlet ~/.config/containers/systemd/element-web.container:
[Unit]
Description=Element Web
After=network-online.target
[Container]
Image=docker.io/vectorim/element-web:latest
ContainerName=element-web
AutoUpdate=registry
Volume=/home/matrix/application-stack/element-web/config.json:/app/config.json:Z,ro
Environment=ELEMENT_WEB_PORT=5080
Network=ext_network.network
PublishPort=127.0.0.1:5080:5080
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
Matrix authentication service quadlet
The MAS quadlet should look like this .config/containers/systemd/mas.container
[Unit]
Description=Matrix Auth
After=network-online.target
Requires=postgresql.service
[Container]
Image=ghcr.io/element-hq/matrix-authentication-service:latest
ContainerName=matrix-auth
AutoUpdate=registry
Environment=MAS_CONFIG=/app/config/config.yaml
Volume=/home/matrix/application-stack/mas/config.yaml:/app/config/config.yaml:Z,ro
HostName=mas
Network=ext_network.network
Network=int_network.network
PublishPort=127.0.0.1:9085:9085
[Service]
Restart=always
TimeoutStartSec=600
[Install]
WantedBy=default.target
Make sure HostName matches the endpoint of the matrix_authentication_service part of your homeserver.yaml.
HINT: If you dont want to autoupdate your container images, remove AutoUpdate=registryfrom you quadlets.
Starting the services for the first time
Now this is the moment of truth… Before we start all containers, we need to actually let systemd know about them (we’re still logged in as the application user matrix!):
systemctl --user daemon-reload
You can of course start all containers at once, but I’d recommend doing that one by one at this point. After starting one, wait a little and see if it stays up. The synapse and mas container might take a little as they will initially create some tables in there respective databases. In case of problems you can either check logs via journalctl --user -u <unit> or - if the container stays up - via podman logs -f <container_name>. See containers running by podman ps. So:
systemctl --user start postgresql
systemctl --user start mas
systemctl --user start synapse
systemctl --user start livekit
systemctl --user start jwt
systemctl --user start element-web
For MAS you can check the configuration syntax by:
podman exec -it matrix-auth mas-cli config check --config=/app/config/config.yaml
And you can also see if it’s working correctly:
podman exec -it matrix-auth mas-cli doctor
You can ignore errors about “.well-known” URIs at this point.
Auto updates
Optionally, you can enable automatic updates of all containers to which you’ve added AutoUpdate=registry in their respective quadlet file by enabling the auto-update service and timer:
systemctl --user enable podman-auto-update.service
systemctl --user enable podman-auto-update.timer
Nginx reverse proxy
If all containers are running fine, we can now configure nginx as one of our last steps. What do we need?
| Container | local port | public port(s) |
|---|---|---|
| synapse | 8008 | 443 + 8448 |
| web | 5080 | 443 |
| mas | 9085 | 443 |
| livekit | 7880 | 443 |
| jwt | 8080 | 443 |
I’d recommend creating a dedicated configuration file in /etc/nginx/sites-available for each (sub)domain. I will outline the important parts only and leave out individual configuration, like path to certificates, ciphers, etc. I basically copied most of that from the official documentation. Of course, for this we need to switch to root again.
example.com
example.com does not really serve any part of the application. So, you could create some generic index.html file to serve. However, we will need to serve some .well-known URIs here:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
root /var/www/html;
index index.html
.
.
.
location / {
# attempt to serve request as file, then as
# directory and then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
location /.well-known/matrix/client {
return 200 '{"m.homeserver": {"base_url": "https://matrix.example.com"}, "org.matrix.msc3881": { "enabled": true, "url": "https://mrtc.matrix.example.com" }, "org.matrix.msc4143.rtc_foci": [ { "type": "livekit", "livekit_service_url": "https://mrtc.example.com/livekit/jwt" } ], "org.matrix.msc2965.authentication": { "issuer": "https://mas.example.com/", "account": "https://mas.example.com/account/" } }';
default_type application/json;
add_header Access-Control-Allow-Origin *;
}
location /.well-known/matrix/server {
return 200 '{"m.server": "matrix.example.com"}';
default_type application/json;
add_header Access-Control-Allow-Origin *;
}
}
matrix.example.com
This is where the synapse server will be reachable. Thus we need to listen to ports 443 and 8448 (federation) and forward that to 8008. Also, we need to forward requests to the authentication service here. Nevertheless, there’s nothing in “/”, thus we can again serve a generic html file here:
server {
listen 443 ssl;
listen [::]:443 ssl;
# For the federation port
listen 8448 ssl default_server;
listen [::]:8448 ssl default_server;
server_name matrix.example.com;
root /var/www/html;
index index.html;
.
.
.
# Forward to the auth service
location ~ ^/_matrix/client/(.*)/(login|logout|refresh) {
proxy_http_version 1.1;
proxy_pass http://localhost:9085;
# OR via the Unix domain socket
#proxy_pass http://unix:/var/run/mas.sock;
# Forward the client IP address
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# or, using the PROXY protocol
#proxy_protocol on;
}
# Forward to Synapse
location ~ ^(/_matrix|/_synapse/client|/_synapse/mas) {
# note: do not add a path (even a single /) after the port in `proxy_pass`,
# otherwise nginx will canonicalise the URI and cause signature verification
# errors.
proxy_pass http://localhost:8008;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Nginx by default only allows file uploads up to 1M in size
# Increase client_max_body_size to match max_upload_size defined in homeserver.yaml
client_max_body_size 50M;
# Synapse responses may be chunked, which is an HTTP/1.1 feature.
proxy_http_version 1.1;
}
}
mas.example.com
All requests for the authentication service need to be forwarded to port 9085:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mas.example.com;
.
.
.
location / {
proxy_http_version 1.1;
proxy_pass http://localhost:9085;
# OR via the Unix domain socket
#proxy_pass http://unix:/var/run/mas.sock;
# Forward the client IP address
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# or, using the PROXY protocol
#proxy_protocol on;
}
}
web.example.com
This one is also quite easy, we just need to forward all requests to our web chat to port 5080:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name web.example.com;
.
.
.
# element-web
location / {
proxy_pass http://localhost:5080;
proxy_set_header Host $host;
}
}
mrtc.example.com
To make Element call work, we’ll need to forward requests to livekit and JWT. As there’s nothing for /, let’s point it to the static index.html file again:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mrtc.example.com;
root /var/www/html;
index index.html;
.
.
.
location ^~ /livekit/jwt/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:8080/;
}
location ^~ /livekit/sfu/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_send_timeout 120;
proxy_read_timeout 120;
proxy_buffering off;
proxy_set_header Accept-Encoding gzip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://localhost:7880/;
}
}
Enable the new configuration
Create symlinks to each configuration file within /etc/nginx/sites-enabled and remove the symlink for default. It should look something like this:
user@server:~# ls -l /etc/nginx/sites-enabled/
total 0
lrwxrwxrwx 1 root root 42 Jun 12 15:27 mas.example.com -> /etc/nginx/sites-available/mas.example.com
lrwxrwxrwx 1 root root 44 Jun 12 15:27 matrix.example.com -> /etc/nginx/sites-available/matrix.example.com
lrwxrwxrwx 1 root root 37 Jun 12 15:27 example.com -> /etc/nginx/sites-available/example.com
lrwxrwxrwx 1 root root 41 Jun 12 15:27 mrtc.example.com -> /etc/nginx/sites-available/mrtc.example.com
lrwxrwxrwx 1 root root 42 Jun 12 15:27 web.example.com -> /etc/nginx/sites-available/web.example.com
Restart nginx:
systemctl restart nginx
If there are no syntax errors it should come up fine - fingers crossed…
Firewall
Lastly, we’ll need to open some additional firewall ports:
ufw allow 8448/tcp
ufw allow 7881/tcp
ufw allow 50000:50200/udp
8448: Matrix federation7881: Livekit (as backup for UDP connections)50000:50200: Livekit
Adding a user
If you disabled user registration, you can add them manually by using mas-cli command within the mas container (as matrix user of course):
podman exec -it matrix-auth mas-cli manage register-user
See here for a list of possible manage commands.
Conclusion
At this point you should have your own E2EE chat server running, including E2EE (video-)calls. It took quite a while for me until everything was set up and you will probably - like me - stumble over a few syntax errors allong the way, but it’s managable. For me stuff like this is always kind of fun - even if it get’s a little frustratng somtimes if things don’t work as expected right away… 😉