Domain, HTTPS and reverse proxies

Put Antimatter on a domain name with HTTPS, using the Caddy profile of the compose file or a reverse proxy of your own, and get the site URL and client addresses right.

The site URL

The site URL (ServiceSettings.SiteURL) is the address people use to reach Antimatter, such as https://chat.example.com. The server uses it in links in emails and notifications, in sign-in redirects (OAuth, OpenID Connect, SAML), for the mobile and desktop apps, and to check where requests come from. Set it to exactly the address in the browser's address bar: scheme, domain and port if not the default, with no trailing slash.

  • With the site's compose file: SITE_URL in .env.
  • Anywhere else: AM_SERVICESETTINGS_SITEURL, or System Console › Environment › Web Server › Site URL.

If the site URL is wrong, sign-in with an identity provider fails, links in emails point to the wrong place, and the apps may not connect. After changing the domain, update it here and in every identity provider's redirect URLs.

HTTPS with the bundled Caddy profile

The site's compose.yaml includes a Caddy service in the https profile. It gets a certificate from Let's Encrypt, renews it by itself, redirects HTTP to HTTPS and forwards everything, websockets included, to Antimatter.

  1. Point a DNS record (A, and AAAA for IPv6) of your domain at the server.
  2. Open ports 80/tcp, 443/tcp and 443/udp (HTTP/3) in the firewall. Let's Encrypt must reach port 80 or 443 from the Internet.
  3. Add the domain to .env and set the site URL:
    echo "DOMAIN=chat.example.com" >> .env
    sed -i 's|^SITE_URL=.*|SITE_URL=https://chat.example.com|' .env
  4. Start with the profile:
    docker compose --profile https up -d

Add --profile https to every later docker compose up, or put COMPOSE_PROFILES=https in .env so that plain docker compose up -d includes it. Caddy keeps its certificates in the https volume.

Antimatter's own port stays published on the host (HTTP_PORT, 8065 by default). With Caddy in front, keep it for the server itself only:

echo "HTTP_PORT=127.0.0.1:8065" >> .env
docker compose --profile https up -d

Caddy reaches Antimatter over the internal edge network, not through that port, so this changes nothing for your users.

With your own reverse proxy (nginx)

If the server already runs nginx, or another proxy, leave the https profile off and point the proxy at Antimatter's port 8065. The proxy must:

  • pass websocket upgrades (the web app and the mobile apps keep a websocket open on /api/v4/websocket, and some apps open their own);
  • accept request bodies at least as large as the largest upload (Maximum File Size, 100 MB by default);
  • keep long-lived connections open (websockets, large uploads and downloads);
  • pass the Host header and the client's address.

A complete server block, adapted from the production template in antimatter-docker. Replace the domain and the certificate paths:

upstream antimatter {
    server 127.0.0.1:8065;
    keepalive 64;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

proxy_cache_path /var/cache/nginx/antimatter levels=1:2 keys_zone=antimatter_cache:10m
                 max_size=3g inactive=120m use_temp_path=off;

server {
    listen 80;
    listen [::]:80;
    server_name chat.example.com;
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name chat.example.com;

    ssl_certificate     /etc/ssl/chat.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/chat.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_timeout 1d;
    ssl_session_cache shared:AntimatterSSL:10m;
    ssl_session_tickets off;

    add_header Strict-Transport-Security "max-age=31536000" always;

    # At least FileSettings.MaxFileSize (100 MB by default).
    client_max_body_size 100M;

    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        $connection_upgrade;
    proxy_buffers 256 16k;
    proxy_buffer_size 16k;

    location ~ ^/api/v[0-9]+/(users/)?websocket$ {
        client_body_timeout 60s;
        send_timeout 300s;
        lingering_timeout 5s;
        proxy_connect_timeout 90s;
        proxy_send_timeout 300s;
        proxy_read_timeout 90s;
        proxy_pass http://antimatter;
    }

    location / {
        proxy_read_timeout 600s;
        proxy_cache antimatter_cache;
        proxy_cache_revalidate on;
        proxy_cache_min_uses 2;
        proxy_cache_use_stale timeout;
        proxy_cache_lock on;
        proxy_pass http://antimatter;
    }
}

Check the file with nginx -t and apply it with systemctl reload nginx. The server sends X-Content-Type-Options, Referrer-Policy, Permissions-Policy and X-Frame-Options itself; only HSTS is up to the proxy. Raise its max-age once HTTPS works, and add includeSubDomains only if every subdomain uses HTTPS.

With Docker Compose and nginx on the host, the upstream above points at the published port 8065. Publish it on 127.0.0.1 only (HTTP_PORT=127.0.0.1:8065), so nobody bypasses the proxy.

The antimatter-docker repository has a full setup with nginx in a container and Let's Encrypt through certbot, in deploy/compose.

Other proxies

Any proxy works if it meets the four points above. Call audio and video never go through the proxy: they use their own port, see Calls and voice.

Client addresses behind a proxy

Behind a proxy, every request seems to come from the proxy. To log the real client address, apply rate limits per client and use IP filtering, tell the server which header carries the address, and which proxies it can believe. Neither setting has a System Console field: set them in config.json or with variables.

Key and variableDefaultWhat it does
ServiceSettings.TrustedProxyIPHeader, AM_SERVICESETTINGS_TRUSTEDPROXYIPHEADERempty: the server uses the address of the connectionThe headers that carry the client's address, for example X-Forwarded-For. The first one present in a request is used.
ServiceSettings.TrustedProxyIPs, AM_SERVICESETTINGS_TRUSTEDPROXYIPSemptyThe addresses or CIDR ranges of your proxies, for example 172.29.71.0/28. The headers only count for requests that come from one of them.

In a variable, separate several values with spaces: AM_SERVICESETTINGS_TRUSTEDPROXYIPHEADER="X-Forwarded-For X-Real-IP". The server refuses a configuration with an entry in TrustedProxyIPs that is neither an IP address nor a CIDR range.

How the server finds the client's address

Each proxy adds the address it received the request from to the right of X-Forwarded-For. Whatever the client sent in the header itself stays on the left. So the server reads the header from the right, skips the addresses of your trusted proxies, and takes the first other address as the client's. A client can't choose its address by sending its own header.

  • Behind one proxy, TrustedProxyIPHeader alone gives the right address: the server takes the right-most address of the header. It logs a warning at startup until you also set TrustedProxyIPs. Without it, anyone who reaches port 8065 directly can set the header, so publish that port on 127.0.0.1 or on a private network only.
  • Behind several proxies, for example a CDN or a load balancer in front of your own proxy, list all of them in TrustedProxyIPs. Otherwise the server takes the outer proxy's address for the client's.
  • The proxy can replace the header with the address it sees, as the nginx example above does (X-Forwarded-For $remote_addr), or append to it (nginx's $proxy_add_x_forwarded_for). Both work.

Which addresses to list

List the address the proxy's requests come from, as Antimatter sees it.

  • With the site's compose file and Caddy, this is already done: Caddy reaches Antimatter over the edge network, and the compose file sets TrustedProxyIPHeader to X-Forwarded-For and TrustedProxyIPs to that network's range, EDGE_SUBNET (172.29.71.0/28 unless you change it in .env). Change EDGE_SUBNET if it overlaps one of your networks.
  • The production setup in antimatter-docker does the same for its nginx container, with FRONTEND_SUBNET.
  • With nginx on the host and Antimatter in Docker, requests reach Antimatter through the published port, usually from the gateway address of the compose project's network (docker network inspect shows it). Set AM_SERVICESETTINGS_TRUSTEDPROXYIPS to that address in compose.override.yaml.
  • Without Docker, with nginx on the same host, it is 127.0.0.1 (and ::1 if nginx connects over IPv6).

TLS without a proxy

The server can also serve HTTPS itself, in System Console › Environment › Web Server: Connection Security set to TLS (ServiceSettings.ConnectionSecurity), with TLS Certificate File and TLS Key File (ServiceSettings.TLSCertFile, ServiceSettings.TLSKeyFile), or Use Let's Encrypt (ServiceSettings.UseLetsEncrypt). The server must then listen on port 443 (Listen Address, ServiceSettings.ListenAddress, :8065 by default), and ServiceSettings.Forward80To443 redirects port 80. A reverse proxy is easier to run and renew, and is what this guide assumes.

Server timeouts and request size

Settings of the server's own web server, in System Console › Environment › Web Server:

SettingKeyDefault
Listen AddressServiceSettings.ListenAddress:8065
Read TimeoutServiceSettings.ReadTimeout300 seconds
Write TimeoutServiceSettings.WriteTimeout300 seconds
Idle timeout (no System Console field)ServiceSettings.IdleTimeout60 seconds
Maximum Payload Size (Bytes)ServiceSettings.MaximumPayloadSizeBytessee Security

Keep the proxy's own timeouts at least as long as these for normal requests, and its body size at least Maximum File Size.