Chat propio con Matrix: Synapse + Element en Docker con HTTPS propio

Homeserver privado con cuentas locales, acceso directo por IP con certificado autofirmado

Guia tecnica para levantar un homeserver Matrix (Synapse), Element Web y un proxy inverso Nginx con certificado autofirmado, todo con Docker Compose. Acceso directo por IP desde cualquier equipo de la red (incluido un Windows 11), por navegador o con la app Element Desktop, sin depender de ningun proveedor externo.

1. Introduccion

Matrix es un protocolo abierto y descentralizado de mensajeria en tiempo real. Synapse es la implementacion de referencia del homeserver (el servidor que aloja las cuentas, las salas y los mensajes), y Element es el cliente mas usado para hablar con el. Esta guia levanta Synapse, su base de datos PostgreSQL, Element Web y un proxy inverso Nginx con Docker, todo autoalojado, sin federar con el resto de la red Matrix y sin depender de ningun proveedor externo.

Un navegador moderno exige que la pagina de un cliente como Element se cargue en un contexto seguro: HTTPS, o localhost. Una IP normal de la red servida por http no cumple esa condicion, y sin ella el navegador deshabilita el cifrado que Element necesita. La solucion correcta, la que se usaria en un servidor real, no es un tunel ni un ajuste del navegador: es poner un proxy inverso con HTTPS delante, aunque sea con un certificado autofirmado mientras no haya un dominio publico. Esta guia lo hace con Nginx y un certificado generado con openssl, y es la razon por la que el acceso final es directo por IP, sin trucos del lado del cliente.

2. Variables de esta guia

Todos los bloques de configuracion usan los siguientes marcadores en MAYUSCULAS. Sustituyelos por los valores reales del entorno donde se aplique esta guia.

MarcadorQue esDonde se obtiene
IP_HOST_DOCKERIP del host Linux donde corre el stack con DockerLa interfaz de red de ese host
SERVER_NAME_MATRIXNombre de dominio interno del homeserver, forma parte del identificador de cada usuario (@usuario:SERVER_NAME_MATRIX)Lo defines tu, por ejemplo clockwork.local; no hace falta que resuelva por DNS porque no vamos a federar
POSTGRES_PASSWORDContraseƱa del usuario de la base de datos de SynapseSe define en /docker/synapse/.env
USUARIO_MATRIX / PASSWORD_MATRIXLa primera cuenta local que se crea en el homeserverLos eliges tu al ejecutar el comando de alta de usuario (seccion 9)
IP_HOST_DOCKER tiene un papel adicional en esta guia: es literalmente el texto que se escribe como CN y subjectAltName al generar el certificado (seccion 7). Si esa IP cambia mas adelante, hay que regenerar el certificado para que siga siendo valido para la nueva direccion.

3. Arquitectura y flujo

Todo el trafico del navegador pasa por un unico punto de entrada, el proxy inverso, que reparte segun la ruta: la interfaz de Element por un lado, la API de Synapse por otro, ambas bajo el mismo origen HTTPS — asi no hay ni contenido mixto ni problemas de CORS entre las dos piezas.

Resumen visual
[Windows / cualquier navegador o Element Desktop]
      |
      |  https://IP_HOST_DOCKER:8443  (certificado autofirmado)
      v
[proxy] :8443 (Nginx, proxy inverso HTTPS)
      |         \
      |          \ /_matrix/*, /_synapse/*
      v            v
[element] :80   [synapse] :8008
                     |
                     v
               [synapse-db]  (PostgreSQL)
Esta guia no configura federacion con el resto de la red Matrix (no se expone el puerto 8448, el habitual para hablar con otros homeservers). Es una decision deliberada: un homeserver privado para un equipo no necesita hablar con matrix.org ni con nadie mas, y evitarlo simplifica muchisimo el montaje — la documentacion oficial es clara en que la federacion si exige un certificado valido, no autofirmado, sin excepciones.

4. Requisitos

  • Un host Linux con Docker Engine y el plugin Docker Compose (docker compose, sin guion) ya instalados.
  • No hace falta salida a Internet desde ese host: todo el flujo de login vive dentro de la red local, sin necesidad de contactar servidores externos.
  • Un navegador cualquiera (Chrome, Firefox, Edge...) en cualquier equipo de la misma red, o la app Element Desktop si se prefiere esa via (seccion 11).

5. Levantar Postgres, Synapse y Element con Docker

Misma organizacion de siempre: cada stack en su carpeta dentro de /docker, credenciales en un .env aparte.

Crear la carpeta del stack
sudo mkdir -p /docker/synapse && cd /docker/synapse
sudo mkdir -p synapse-data
Crear .env
sudo nano .env
.env
POSTGRES_USER=synapse
POSTGRES_PASSWORD=synapse_pwd
POSTGRES_DB=synapse

Ahora crea el archivo de Element:

Crear element-config.json
sudo nano element-config.json
element-config.json
{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://IP_HOST_DOCKER:8443",
      "server_name": "SERVER_NAME_MATRIX"
    }
  },
  "brand": "Clockwork Computer Chat"
}
Ojo, son dos cosas distintas dentro del mismo archivo: base_url es la direccion real a la que Element se conecta (ahi si va la IP), pero server_name no es una direccion de red, es la segunda mitad del identificador de cada usuario, y queda fijado para siempre en cuanto se genere homeserver.yaml en el paso 6. Por eso conviene usar algo inventado como clockwork.local en vez de la IP para ese campo.

Y ahora el docker-compose.yml:

Crear docker-compose.yml
sudo nano docker-compose.yml
docker-compose.yml
services:
  synapse-db:
    image: postgres:16-alpine
    container_name: synapse-db
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
    volumes:
      - synapse-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 20s
    restart: unless-stopped

  synapse:
    image: matrixdotorg/synapse:latest
    container_name: synapse
    volumes:
      - ./synapse-data:/data
    ports:
      - "8008:8008"
    depends_on:
      synapse-db:
        condition: service_healthy
    restart: unless-stopped

  element:
    image: vectorim/element-web:latest
    container_name: element
    volumes:
      - ./element-config.json:/app/config.json:ro
    depends_on:
      - synapse
    restart: unless-stopped

  proxy:
    image: nginx:alpine
    container_name: proxy
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./proxy-selfsigned.crt:/etc/nginx/certs/proxy-selfsigned.crt:ro
      - ./proxy-selfsigned.key:/etc/nginx/certs/proxy-selfsigned.key:ro
    ports:
      - "8443:8443"
    depends_on:
      - synapse
      - element
    restart: unless-stopped

volumes:
  synapse-db-data:
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" no es opcional, es la trampa mas repetida al montar Synapse: si la base de datos se crea con el locale por defecto de la imagen, Synapse se niega a arrancar con un error de "Database has incorrect collation... Should be 'C'". Solo se aplica en la primera inicializacion del volumen; si ya arrancaste una vez sin este flag, hay que borrar el volumen y empezar de cero.
El servicio element no publica ningun puerto al host: solo es alcanzable a traves de proxy, por dentro de la red de Docker. Es proxy el unico punto de entrada desde fuera.

6. Generar la configuracion de Synapse

Synapse necesita un homeserver.yaml antes de arrancar de verdad. Se genera con un comando de un solo uso, usando el propio servicio ya definido en el docker-compose.yml:

Generar homeserver.yaml
cd /docker/synapse
docker compose run --rm -e SYNAPSE_SERVER_NAME=SERVER_NAME_MATRIX -e SYNAPSE_REPORT_STATS=no synapse generate

Esto crea ./synapse-data/homeserver.yaml y una clave de firma, ya con un registration_shared_secret aleatorio incluido — no hay que generarlo ni copiarlo a mano, el comando de alta de usuarios del paso 9 lo lee directamente del propio archivo.

Edita el archivo generado:

Editar homeserver.yaml
sudo nano ./synapse-data/homeserver.yaml

Busca el bloque database: (por defecto viene configurado para SQLite) y sustituyelo entero por este, apuntando a Postgres:

Bloque database: en homeserver.yaml
database:
  name: psycopg2
  args:
    user: synapse
    password: synapse_pwd
    database: synapse
    host: synapse-db
    port: 5432
    cp_min: 5
    cp_max: 10
host: synapse-db es el nombre del servicio en docker-compose.yml, no localhost. Dentro de la red de Compose, los contenedores se hablan por nombre de servicio, nunca por localhost. Si dejaste user y password con otros valores en el .env del paso 5, aqui deben coincidir exactamente.

7. Configurar el proxy inverso con HTTPS propio

Primero, genera un certificado autofirmado con clave EC (curva eliptica), valido para la IP del host Docker:

Generar el certificado autofirmado
cd /docker/synapse
sudo openssl ecparam -name prime256v1 -genkey -noout -out proxy-selfsigned.key
sudo openssl req -new -x509 -key proxy-selfsigned.key -out proxy-selfsigned.crt -days 825 \
  -subj "/CN=IP_HOST_DOCKER" \
  -addext "subjectAltName=IP:IP_HOST_DOCKER"

Ahora crea la configuracion de Nginx:

Crear nginx.conf
sudo nano nginx.conf
nginx.conf
resolver 127.0.0.11 valid=10s;

server {
    listen 8443 ssl;
    server_name IP_HOST_DOCKER;

    ssl_certificate     /etc/nginx/certs/proxy-selfsigned.crt;
    ssl_certificate_key /etc/nginx/certs/proxy-selfsigned.key;

    location ~ ^/(_matrix|_synapse) {
        set $upstream_synapse http://synapse:8008;
        proxy_pass $upstream_synapse;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        set $upstream_element http://element:80;
        proxy_pass $upstream_element;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
El resolver 127.0.0.11 (el DNS interno de Docker) combinado con set $upstream_... ; proxy_pass $upstream_...; es imprescindible, no un capricho de estilo: sin la variable, Nginx intenta resolver synapse y element una sola vez al arrancar, con el resolvedor normal del sistema, y si en ese instante no lo consigue se niega a arrancar con un error de host not found in upstream. Con la variable, la resolucion se hace bajo demanda en cada peticion, tolerando que el contenedor tarde en estar listo o se recree mas adelante con otra IP interna.

8. Arrancar el stack y verificar Synapse

Arrancar los contenedores
cd /docker/synapse
docker compose up -d
docker compose ps

Sigue el log del homeserver:

Log de Synapse
docker logs -f synapse

Busca una linea similar a "Synapse now listening on TCP port 8008", sin errores de collation ni excepciones de Python por el camino. Sal con Ctrl+C.

Comprueba tambien que el proxy responde correctamente antes de pasar al navegador:

Probar el proxy desde el propio host
curl -kv https://IP_HOST_DOCKER:8443/_matrix/client/versions
Si devuelve un JSON con las versiones del protocolo Matrix, confirma que el proxy esta reenviando correctamente a Synapse por HTTPS.

9. Crear los usuarios

Las cuentas en Synapse se crean con un comando de administracion, ejecutado dentro del propio contenedor:

Crear un usuario administrador
docker exec -it synapse register_new_matrix_user \
  http://localhost:8008 \
  -c /data/homeserver.yaml \
  -u USUARIO_MATRIX \
  -p PASSWORD_MATRIX \
  -a

El flag -a marca la cuenta como administradora del homeserver.

Esta guia deja enable_registration en su valor por defecto (false): nadie puede registrarse solo desde el cliente, todas las cuentas se crean con este comando por un administrador.

Para crear una segunda cuenta (por ejemplo, una que no sea administradora), se repite el mismo comando cambiando el usuario y la contraseƱa, sustituyendo -a por --no-admin:

Crear una segunda cuenta (no administradora)
docker exec -it synapse register_new_matrix_user \
  http://localhost:8008 \
  -c /data/homeserver.yaml \
  -u USUARIO2_MATRIX \
  -p PASSWORD2_MATRIX \
  --no-admin

10. Acceder desde el navegador

Abre cualquier navegador y entra en:

URL de acceso
https://IP_HOST_DOCKER:8443
El navegador va a mostrar un aviso de conexion no privada o certificado no confiable. Es esperado: el certificado es autofirmado, no lo ha emitido una autoridad publica reconocida por el navegador. Pulsa "Avanzado" y "Continuar a IP_HOST_DOCKER (no seguro)" (el texto exacto varia segun el navegador) para seguir. Es la misma situacion que se encuentra con muchisimos servicios internos en empresas reales antes de tener un certificado firmado por una entidad publica.
Tras aceptar el aviso, deberia cargar la pantalla de login de Element sin ningun error de "navegador no compatible". Inicia sesion con USUARIO_MATRIX y PASSWORD_MATRIX, crea una sala de prueba y escribe un mensaje.

11. Alternativa: Element Desktop

Si se prefiere no lidiar con el aviso de certificado, tambien se puede instalar Element Desktop (element.io/download) y conectarla directamente al puerto de Synapse sin pasar por el proxy, ya que al ser una aplicacion nativa no esta sujeta al requisito de contexto seguro del navegador:

Homeserver personalizado en Element Desktop
http://IP_HOST_DOCKER:8008

Ambas vias llevan al mismo homeserver y a las mismas cuentas; son formas distintas de conectarse, no montajes separados.

12. Tabla de puertos y componentes

PuertoComponenteSentido de la conexionPara que sirve
8443/tcpproxy (Docker, Nginx)Navegador → contenedorProxy inverso HTTPS (certificado autofirmado); reparte a Element y a Synapse segun la ruta
8008/tcpsynapse (Docker)Element Desktop → contenedorAPI cliente-servidor de Matrix, accesible tambien directo (sin HTTPS) para la app de escritorio
5432/tcpsynapse-db (Docker, interno)synapse → synapse-dbConexion a PostgreSQL; no necesita exponerse al host
element (Docker, interno)proxy → elementNo publica ningun puerto al host; solo accesible a traves de proxy
8448/tcpNo usado en esta guiaPuerto de federacion con otros homeservers; deliberadamente no expuesto

13. Errores comunes y como resolverlos

ProblemaCausa probableSolucion
Synapse falla al arrancar con Database has incorrect collation... Should be 'C' El volumen de Postgres se creo sin POSTGRES_INITDB_ARGS, con el locale por defecto de la imagen Borrar el volumen (docker compose down -v) y volver a arrancar con el flag ya incluido en el docker-compose.yml (seccion 5)
Synapse no arranca, error de conexion a la base de datos host en el bloque database: de homeserver.yaml apunta a localhost en vez de synapse-db, o las credenciales no coinciden con el .env Revisar el bloque database: caracter a caracter (seccion 6)
El navegador o curl -k dan un error de TLS tipo alert internal error al conectar al proxy Fallo especifico de algunos entornos con el mecanismo de certificados de Caddy; no ocurre con Nginx Esta guia ya usa Nginx con un certificado generado a mano precisamente para evitar este problema; si aparece igualmente, comprobar con openssl s_server si el propio sistema es capaz de completar un handshake TLS con esos mismos archivos, para descartar causas mas de base
El contenedor proxy se reinicia en bucle con host not found in upstream "synapse" Nginx intento resolver el nombre del contenedor una sola vez al arrancar y no lo consiguio a tiempo Usar resolver 127.0.0.11 junto con set $upstream_...; proxy_pass $upstream_...; en vez de un proxy_pass directo (seccion 7), para que la resolucion sea bajo demanda
Tras cambiar el docker-compose.yml, sigue respondiendo el servicio antiguo en el mismo puerto Un contenedor de un servicio ya eliminado del compose sigue vivo (Compose no lo para si ya no aparece en el archivo) docker ps para localizarlo, y docker stop <nombre> && docker rm <nombre> antes de volver a levantar el stack
register_new_matrix_user falla o no encuentra el archivo de configuracion Ruta incorrecta en -c, o el contenedor synapse no esta corriendo todavia Confirmar con docker compose ps que synapse esta Up, y que la ruta es /data/homeserver.yaml (la ruta dentro del contenedor, no la del host)
Un usuario nuevo no puede registrarse el mismo desde Element Comportamiento esperado: enable_registration esta en false a proposito Crear la cuenta con register_new_matrix_user (seccion 9)

14. Notas de ampliacion

El montaje de esta guia cubre lo minimo necesario para tener un chat propio funcionando con cuentas locales, cifrado con HTTPS y acceso directo por IP. Para un entorno de uso continuado, conviene ampliar con:

  • Importar el certificado en el almacen de confianza de cada equipo cliente (Windows, Mac, Linux), para que el aviso de "certificado no confiable" desaparezca de forma permanente en esos equipos, sin tener que aceptarlo cada vez.
  • Dominio propio + Let's Encrypt en vez de certificado autofirmado, necesario si en algun momento se quiere activar la federacion con otros homeservers Matrix, que exige un certificado valido reconocido publicamente.
  • Apps moviles (Element para Android e iOS), que pueden apuntar al mismo homeserver desde fuera de la red local una vez este detras de un dominio con HTTPS valido.
  • Puentes (bridges) hacia otras plataformas como Slack, Discord o Telegram, para centralizar conversaciones de varias herramientas en el mismo cliente.
  • Copias de seguridad del volumen de PostgreSQL y de la carpeta synapse-data (incluye las claves de firma del servidor, necesarias para que el homeserver mantenga su identidad).