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.

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.
| stable | Sigue 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. |
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
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
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.
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.
| 8080:8080 | Interfaz web y API. |
| 8333:8333 | Almacenamiento 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. |
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_URL | La dirección por la que la gente llega a esta instancia. Cada enlace de invitación se construye a partir de ella. |
| SETUP_TOKEN | Autoriza la primera configuración y, después, el restablecimiento de contraseñas. Dieciséis caracteres o más. |
| DB_PASSWORD | Contraseñ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_SECRET | Firma cada sesión. 32 caracteres o más; cambiarlo cierra la sesión de todos. |
| STORAGE_ACCESS_KEY STORAGE_SECRET_KEY | Credenciales del almacenamiento de objetos incluido. Firman cada subida y cada descarga. |
| STORAGE_PUBLIC_ENDPOINT | La 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_PORT | El puerto del host en el que se publica la aplicación. Cámbialo si 8080 está ocupado. |
| STORAGE_PORT | El puerto del host en el que se publica el almacenamiento incluido. Cámbialo si 8333 está ocupado. |
| STORAGE_ENDPOINT | Dó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_SSL | false para el almacenamiento incluido, que habla HTTP simple. true para un proveedor que sirve TLS. |
| STORAGE_BUCKET_NAME | El 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_LIMIT | Intentos 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
| DOMAIN | Solo 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.
| postgres_data | Cada lugar, visita, ficha de foto y cuenta. |
| storage_data | Las fotos y los vídeos en sí. |
| data_protection_keys | Claves de protección de datos. Perderlas cierra la sesión de todos; no se pierde ningún dato. |
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
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
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.

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.
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.
| La app se reinicia un par de veces en el primer arranque | PostgreSQL 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 unhealthy | El 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 navegador | STORAGE_PUBLIC_ENDPOINT no es accesible desde el navegador, o el puerto del almacenamiento no está publicado. |
| Todos han perdido la sesión tras un reinicio | Falta el volumen data_protection_keys o se ha vuelto a crear. |
| La página de configuración dice que el token es incorrecto | El valor no es el SETUP_TOKEN del .env. |
| Edición | Ni 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ímites | Sin cuota de almacenamiento y sin tope de miembros: no hay ningún plan que los limite. |
| Licencia | Gratis 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. |
| Correo | Ninguno. 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. |
| Imagen | La 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
🇪🇺 Creado y alojado en Europa
© 2026 Memory Atlas
Esta página también está disponible en inglés.
English