Memory Atlas, autoalojado

Memory Atlas en hardware que controlas, como tres contenedores: la aplicación, PostgreSQL y el almacenamiento de objetos. La misma aplicación que el servicio alojado, sin facturación, sin panel de operador y sin páginas de marketing.

Imagen en Docker Hub
La página de configuración de una instancia nueva: un campo para el token de configuración y, después, nombre, correo y contraseña.

Lo que necesitas

  • Docker con Compose v2: el subcomando docker compose, no el antiguo docker-compose. Toda la pila se ejecuta en contenedores.
  • Una máquina de 64 bits — linux/amd64 o linux/arm64. Se publican ambas: un servidor x86-64 o una placa arm64 (Raspberry Pi 4 o 5 con sistema de 64 bits, un NAS ARM, Apple silicon). ARM de 32 bits no se publica.
  • Una máquina que esté encendida, porque ella es el servidor. Sirve un ordenador de sobra, un NAS o un VPS pequeño.
  • Unos 3 GB de disco para empezar. La imagen de la aplicación ocupa 1,7 GB; el resto lo llenan fotos y vídeos.

No hacen falta dominio, base de datos ni cuenta de almacenamiento, y no hay nada que configurar para el correo. HTTPS es lo único que se puede añadir después.

Etiquetas de versión

stableSigue la última publicación y avanza cuando haces pull.
<version>Una versión publicada, fijada. Úsala para decidir tú cuándo avanza esta instancia.
sha-<commit>El commit exacto con el que se compiló una imagen.

Uso

Los dos archivos van en una carpeta propia. Compose lee compose.yaml y .env por su nombre, así que ningún comando tiene que nombrarlos.

compose.yaml

name: memory-atlas-selfhost

services:
  postgres:
    image: postgis/postgis:17-3.5-alpine
    environment:
      POSTGRES_DB: memory_atlas
      POSTGRES_USER: memory_atlas
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      # The app migrates the database as it starts and does not retry: on a first boot it must not
      # reach Postgres before Postgres accepts connections.
      test: ["CMD-SHELL", "pg_isready -U memory_atlas -d memory_atlas"]
      interval: 5s
      timeout: 5s
      retries: 12
    restart: unless-stopped

  storage:
    image: chrislusf/seaweedfs:latest
    command: "server -s3 -dir=/data"
    environment:
      S3_ACCESS_KEY: ${STORAGE_ACCESS_KEY:?set STORAGE_ACCESS_KEY in .env}
      S3_SECRET_KEY: ${STORAGE_SECRET_KEY:?set STORAGE_SECRET_KEY in .env}
    ports:
      - "${STORAGE_PORT:-8333}:8333"
    volumes:
      - storage_data:/data
    healthcheck:
      # A bare GET on the S3 port answers 200 with no credentials and no signing, which makes it the
      # cheapest way to tell "serving" from "merely bound". 127.0.0.1 rather than localhost, which
      # also resolves to ::1 while SeaweedFS listens on IPv4 only.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
      interval: 5s
      timeout: 3s
      retries: 12
    restart: unless-stopped

  app:
    image: docker.io/gotnonamedocker/memoryatlas-selfhost:stable
    ports:
      - "${APP_PORT:-8080}:8080"
    environment:
      App__BaseUrl: ${APP_URL:?set APP_URL in .env}
      Jwt__SecretKey: ${JWT_SECRET:?set JWT_SECRET in .env}
      Setup__Token: ${SETUP_TOKEN:?set SETUP_TOKEN in .env}
      Storage__AccessKey: ${STORAGE_ACCESS_KEY:?set STORAGE_ACCESS_KEY in .env}
      Storage__SecretKey: ${STORAGE_SECRET_KEY:?set STORAGE_SECRET_KEY in .env}
      Storage__PublicEndpoint: ${STORAGE_PUBLIC_ENDPOINT:?set STORAGE_PUBLIC_ENDPOINT in .env}
      ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=memory_atlas;Username=memory_atlas;Password=${DB_PASSWORD:?set DB_PASSWORD in .env}"
      Storage__Endpoint: ${STORAGE_ENDPOINT:-storage:8333}
      Storage__UseSsl: ${STORAGE_USE_SSL:-false}
      Storage__BucketName: ${STORAGE_BUCKET_NAME:-memory-atlas}
      RateLimit__AuthPermitLimit: ${AUTH_RATE_LIMIT:-20}
    volumes:
      - data_protection_keys:/home/app/.aspnet/DataProtection-Keys
    depends_on:
      postgres:
        condition: service_healthy
      storage:
        condition: service_healthy
    restart: unless-stopped

volumes:
  postgres_data:
  storage_data:
  data_protection_keys:

.env

DB_PASSWORD=
STORAGE_ACCESS_KEY=
STORAGE_SECRET_KEY=
JWT_SECRET=
SETUP_TOKEN=

APP_URL=http://localhost:8080
STORAGE_PUBLIC_ENDPOINT=localhost:8333

Genera los secretos

for k in DB_PASSWORD STORAGE_ACCESS_KEY STORAGE_SECRET_KEY JWT_SECRET SETUP_TOKEN; do
  printf '%s=%s\n' "$k" "$(openssl rand -hex 32)"
done

Arranca

docker compose up -d

El primer arranque tarda alrededor de un minuto: la aplicación aplica sus migraciones de base de datos y crea su bucket de almacenamiento. Después abre la dirección de APP_URL; mientras no exista una primera cuenta, cualquier dirección de la instancia lleva a la página de configuración.

Guarda SETUP_TOKEN fuera del servidor. Es lo que crea la primera cuenta y el único modo de restablecer una contraseña aquí: esta edición no envía correo, así que no hay ningún enlace que esperar. Si se pierde, el contenedor todavía lo tiene: el comando está en la sección «Contraseñas», más abajo.

Parámetros

Los puertos se escriben host:container, así que 8080:8080 publica el puerto 8080 de la aplicación en el puerto 8080 del host. Todo lo demás son variables de entorno, leídas del .env junto a compose.yaml.

Puertos

8080:8080Interfaz web y API.
8333:8333Almacenamiento de objetos. El navegador sube directamente aquí, así que este puerto tiene que ser accesible desde el navegador. Al añadir HTTPS más abajo, es Caddy quien sirve el almacenamiento desde el propio origen de la aplicación.

Variables de entorno

Solo el grupo «Obligatorias» tiene que estar en el .env. El archivo compose ya pasa todo lo demás con el valor indicado; añade una línea en el .env para cambiarlo.

Obligatorias

APP_URLLa dirección por la que la gente llega a esta instancia. Cada enlace de invitación se construye a partir de ella.
SETUP_TOKENAutoriza la primera configuración y, después, el restablecimiento de contraseñas. Dieciséis caracteres o más.
DB_PASSWORDContraseña del PostgreSQL incluido. Solo se usa dentro de la pila, y queda fijada en la base de datos la primera vez que se crea.
JWT_SECRETFirma cada sesión. 32 caracteres o más; cambiarlo cierra la sesión de todos.
STORAGE_ACCESS_KEY STORAGE_SECRET_KEYCredenciales del almacenamiento de objetos incluido. Firman cada subida y cada descarga.
STORAGE_PUBLIC_ENDPOINTLa dirección que usa el navegador para el almacenamiento de objetos — no la que usa la aplicación. host:port con HTTP simple, o también el esquema (https://<domain>) cuando añadas HTTPS más abajo.

Las pone la pila

APP_PORTEl puerto del host en el que se publica la aplicación. Cámbialo si 8080 está ocupado.
STORAGE_PORTEl puerto del host en el que se publica el almacenamiento incluido. Cámbialo si 8333 está ocupado.
STORAGE_ENDPOINTDónde llega la aplicación al almacenamiento dentro de la pila: storage:8333, el servicio de almacenamiento. Apúntalo a otro sitio para usar un almacenamiento que ya tengas.
STORAGE_USE_SSLfalse para el almacenamiento incluido, que habla HTTP simple. true para un proveedor que sirve TLS.
STORAGE_BUCKET_NAMEEl bucket en el que la aplicación guarda sus objetos. Se crea en el primer arranque. Cambiarlo después oculta las fotos que ya están ahí.
AUTH_RATE_LIMITIntentos de acceso y de configuración por dirección y minuto, por encima del valor propio de la aplicación porque la primera configuración son varias llamadas protegidas seguidas de una sola persona.

Opcionales

DOMAINSolo lo lee el servicio HTTPS de más abajo, donde es el nombre del certificado.

Cualquier ajuste de la configuración de la aplicación se puede poner igual, con dos guiones bajos por cada dos puntos: Upload__MaxVideoSizeMB=2048. Añádelo al bloque environment: del servicio app.

Volúmenes

postgres_dataCada lugar, visita, ficha de foto y cuenta.
storage_dataLas fotos y los vídeos en sí.
data_protection_keysClaves de protección de datos. Perderlas cierra la sesión de todos; no se pierde ningún dato.

Opcional: HTTPS con un dominio

Caddy obtiene y renueva el certificado por sí solo, y sirve el almacenamiento de objetos desde el mismo origen: un navegador en una página https no sube a una dirección http. Añade este servicio a compose.yaml, y los dos volúmenes que usa a la lista del final del mismo archivo:

compose.yaml

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    environment:
      DOMAIN: ${DOMAIN:?set DOMAIN in .env}
      STORAGE_BUCKET_NAME: ${STORAGE_BUCKET_NAME:-memory-atlas}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app
    restart: unless-stopped

compose.yaml

volumes:
  postgres_data:
  storage_data:
  data_protection_keys:
  caddy_data:
  caddy_config:

Guarda el Caddyfile junto a compose.yaml, sin cambios. Envía la ruta del bucket al almacenamiento y todo lo demás a la aplicación, evita que los tiempos de espera corten un vídeo grande y mantiene la instancia fuera de los buscadores:

Caddyfile

{$DOMAIN} {
    handle /{$STORAGE_BUCKET_NAME:memory-atlas}/* {
        reverse_proxy storage:8333 {
            transport http {
                read_timeout 0s
                write_timeout 0s
            }
        }
    }

    handle {
        reverse_proxy app:8080 {
            transport http {
                read_timeout 0s
                write_timeout 0s
            }
        }
    }

    encode gzip

    header {
        X-Forwarded-Proto https
        X-Forwarded-For {remote_host}
        X-Robots-Tag "noindex, nofollow"
    }
}

Después pon DOMAIN en el dominio, y APP_URL y STORAGE_PUBLIC_ENDPOINT en https://<domain> — con el esquema, sin el puerto. El DNS del dominio ya tiene que apuntar a esta máquina y los puertos 80 y 443 tienen que ser accesibles, porque Caddy le pide el certificado a Let's Encrypt en el primer arranque. Vuelve a arrancar la pila:

docker compose up -d

Actualizar

Los secretos del .env y los tres volúmenes sobreviven. Las migraciones de base de datos se ejecutan al arrancar, así que no hay un paso de migración.

docker compose pull && docker compose up -d

Contraseñas

Esta edición no envía correo, así que no hay enlace de restablecimiento que esperar: la contraseña se cambia en /setup, con el token de configuración. Es la misma página que creó la primera cuenta y sigue funcionando después de configurar la instancia.

La página de configuración de una instancia ya configurada: el campo del token de configuración, después el correo de la cuenta, una contraseña nueva y el botón Change password.
  1. Abre la página de configuración: APP_URL/setup, la dirección que se usa para esta instancia.
  2. Pega el token de configuración: SETUP_TOKEN en el archivo de entorno de la pila, o el valor que imprime el contenedor en ejecución con el comando de abajo.
  3. Escribe el correo de la cuenta y una contraseña nueva, y elige Change password. No hay paso de confirmación y no se envía nada: la contraseña nueva funciona en el siguiente inicio de sesión.
docker compose exec app printenv Setup__Token

Se cierran todas las sesiones de esa cuenta, porque el restablecimiento revoca sus tokens de actualización. Nada más cambia en la instancia.

Los miembros no pueden hacerlo por su cuenta. La página de inicio de sesión lo dice en lugar de ofrecer un enlace de restablecimiento, así que quien olvide su contraseña te la pide a ti.

Copias de seguridad

Detén la pila primero, para que la copia no se tome a mitad de una escritura.

for v in postgres_data storage_data data_protection_keys; do
  docker run --rm -v memory-atlas-selfhost_$v:/data -v "$PWD":/backup alpine \
    tar czf /backup/$v.tar.gz -C /data .
done

El .env contiene las claves que hacen legibles los volúmenes, así que una copia sin él no es una copia de seguridad. Para restaurar: vuelve a crear la carpeta, pon tu propio .env antes del primer arranque, descomprime los archivos en los mismos nombres de volumen y arráncala.

Solución de problemas

La app se reinicia un par de veces en el primer arranquePostgreSQL todavía se está inicializando y rechaza las conexiones. La política de reinicio devuelve la app cuando está listo. Ocurre una vez, en una instalación nueva.
/health informa de objectStorage como unhealthyEl almacenamiento aún no está levantado, o su bucket no se pudo crear. Comprueba las credenciales y los endpoints.
Las fotos se suben desde el servidor pero no desde el navegadorSTORAGE_PUBLIC_ENDPOINT no es accesible desde el navegador, o el puerto del almacenamiento no está publicado.
Todos han perdido la sesión tras un reinicioFalta el volumen data_protection_keys o se ha vuelto a crear.
La página de configuración dice que el token es incorrectoEl valor no es el SETUP_TOKEN del .env.

Notas

EdiciónNi facturación, ni panel de operador, ni páginas de marketing. No están en esta imagen en absoluto, y la compilación falla si alguno vuelve.
LímitesSin cuota de almacenamiento y sin tope de miembros: no hay ningún plan que los limite.
LicenciaGratis para uso propio o dentro de tu organización, en tantas instancias como quieras. No es gratis revenderlo ni ofrecerlo a otros como servicio alojado: eso requiere una licencia comercial. Las condiciones completas se incluyen con la imagen como LICENSE.
CorreoNinguno. Esta edición no envía nada: las invitaciones son enlaces que copias de la página de miembros y envías tú, y una contraseña se restablece desde la página de configuración con el token de configuración.
ImagenLa imagen autoalojada se publica aparte de la del servicio alojado, porque la compilación alojada incluye los proveedores de pago y el panel de operador.
Memory Atlas

Memory Atlas

🇪🇺 Creado y alojado en Europa

© 2026 Memory Atlas

Esta página también está disponible en inglés.

English