Memory Atlas, auto-hébergé

Memory Atlas sur du matériel que tu contrôles, en trois conteneurs : l'application, PostgreSQL et le stockage objet. La même application que le service hébergé, sans facturation, sans panneau opérateur et sans pages marketing.

Image sur Docker Hub
La page d'installation d'une nouvelle instance : un champ pour le jeton d'installation, puis nom, e-mail et mot de passe.

Ce qu'il faut

  • Docker avec Compose v2 — la sous-commande docker compose, pas l'ancien docker-compose. Toute la pile tourne en conteneurs.
  • Une machine 64 bits — linux/amd64 ou linux/arm64. Les deux sont publiées : un serveur x86-64, ou une carte arm64 (Raspberry Pi 4 ou 5 sous système 64 bits, un NAS ARM, Apple silicon). L'ARM 32 bits n'est pas publié.
  • Une machine qui reste allumée, puisque c'est elle le serveur. Un ordinateur en trop, un NAS ou un petit VPS font l'affaire.
  • Environ 3 Go d'espace disque pour commencer. L'image de l'application fait 1,7 Go ; le reste, ce sont tes photos et vidéos.

Aucun domaine, aucune base de données ni compte de stockage n'est nécessaire, et il n'y a rien à configurer pour le mail. HTTPS est la seule chose qui puisse s'ajouter ensuite.

Tags de version

stableSuit la dernière version publiée et avance quand tu fais un pull.
<version>Une version publiée, épinglée. Utilise-la pour décider toi-même quand cette instance évolue.
sha-<commit>Le commit exact à partir duquel une image a été construite.

Utilisation

Les deux fichiers vont dans un dossier à eux. Compose lit compose.yaml et .env par leur nom, donc aucune commande n'a besoin de les nommer.

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

Générer les secrets

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

Démarrer

docker compose up -d

Le premier démarrage prend environ une minute : l'application applique ses migrations de base de données et crée son bucket de stockage. Ouvre ensuite l'adresse indiquée dans APP_URL — tant qu'aucun premier compte n'existe, toute adresse de l'instance mène à la page d'installation.

Garde SETUP_TOKEN ailleurs que sur le serveur. C'est lui qui crée le premier compte, et le seul moyen de réinitialiser un mot de passe ici — cette édition n'envoie aucun e-mail, il n'y a donc aucun lien à attendre. S'il disparaît, le conteneur l'a encore : la commande se trouve dans la section « Mots de passe » plus bas.

Paramètres

Les ports s'écrivent host:container : 8080:8080 publie le port 8080 de l'application sur le port 8080 de l'hôte. Tout le reste est une variable d'environnement, lue dans le .env à côté de compose.yaml.

Ports

8080:8080Interface web et API.
8333:8333Stockage objet. Le navigateur envoie directement ici, donc ce port doit être joignable depuis le navigateur. En ajoutant HTTPS plus bas, c'est Caddy qui sert le stockage depuis l'origine de l'application.

Variables d'environnement

Seul le groupe « Obligatoires » doit figurer dans le .env. Le fichier compose transmet déjà tout le reste avec la valeur indiquée ; ajoute une ligne dans le .env pour la changer.

Obligatoires

APP_URLL'adresse par laquelle on atteint cette instance. Chaque lien d'invitation est construit à partir d'elle.
SETUP_TOKENAutorise la première installation, puis la réinitialisation des mots de passe. Seize caractères ou plus.
DB_PASSWORDMot de passe du PostgreSQL embarqué. Utilisé uniquement à l'intérieur de la pile, et fixé dans la base la première fois qu'elle est créée.
JWT_SECRETSigne chaque session. 32 caractères ou plus ; le changer déconnecte tout le monde.
STORAGE_ACCESS_KEY STORAGE_SECRET_KEYIdentifiants du stockage objet embarqué. Ils signent chaque envoi et chaque téléchargement.
STORAGE_PUBLIC_ENDPOINTL'adresse que le navigateur utilise pour le stockage objet — pas celle qu'utilise l'application. host:port en HTTP simple, ou avec le schéma (https://<domain>) dès que tu ajoutes HTTPS plus bas.

Définies par la pile

APP_PORTLe port de l'hôte sur lequel l'application est publiée. Change-le si 8080 est pris.
STORAGE_PORTLe port de l'hôte sur lequel le stockage embarqué est publié. Change-le si 8333 est pris.
STORAGE_ENDPOINTOù l'application atteint le stockage à l'intérieur de la pile : storage:8333, le service de stockage. Pointe-le ailleurs pour utiliser un stockage que tu as déjà.
STORAGE_USE_SSLfalse pour le stockage embarqué, qui parle HTTP simple. true pour un fournisseur qui sert du TLS.
STORAGE_BUCKET_NAMELe bucket où l'application range ses objets. Créé au premier démarrage. Le changer ensuite masque les photos déjà présentes.
AUTH_RATE_LIMITTentatives de connexion et d'installation par adresse et par minute, au-dessus de la valeur par défaut de l'application car la première installation est une suite d'appels protégés faits par une seule personne.

Facultatives

DOMAINLu uniquement par le service HTTPS plus bas, où il est le nom du certificat.

N'importe quel réglage de la configuration de l'application se donne de la même façon, avec deux underscores à la place de chaque deux-points : Upload__MaxVideoSizeMB=2048. Ajoute-le au bloc environment: du service app.

Volumes

postgres_dataChaque lieu, visite, fiche photo et compte.
storage_dataLes photos et les vidéos elles-mêmes.
data_protection_keysClés de protection des données. Les perdre déconnecte tout le monde ; aucune donnée n'est perdue.

Facultatif : HTTPS avec un domaine

Caddy obtient et renouvelle le certificat lui-même, et sert le stockage objet depuis la même origine — un navigateur sur une page https n'envoie pas vers une adresse http. Ajoute ce service à compose.yaml, et les deux volumes qu'il utilise à la liste à la fin du même fichier :

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:

Enregistre le Caddyfile à côté de compose.yaml, sans le modifier. Il envoie le chemin du bucket vers le stockage et tout le reste vers l'application, empêche les délais d'expédition de couper une grosse vidéo, et tient l'instance hors des moteurs de recherche :

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"
    }
}

Renseigne ensuite DOMAIN avec le domaine, et APP_URL et STORAGE_PUBLIC_ENDPOINT avec https://<domain> — avec le schéma, sans le port. Le DNS du domaine doit déjà pointer vers cette machine et les ports 80 et 443 doivent être joignables, car Caddy demande le certificat à Let's Encrypt au premier démarrage. Redémarre la pile :

docker compose up -d

Mise à jour

Les secrets du .env et les trois volumes survivent. Les migrations de base de données s'exécutent au démarrage : il n'y a donc pas d'étape de migration.

docker compose pull && docker compose up -d

Mots de passe

Cette édition n'envoie aucun e-mail : il n'y a donc pas de lien de réinitialisation à attendre. Un mot de passe se change sur /setup, avec le jeton d'installation. C'est la même page que celle qui a créé le premier compte, et elle continue de fonctionner une fois l'instance installée.

La page d'installation d'une instance déjà configurée : le champ du jeton d'installation, puis l'e-mail du compte, un nouveau mot de passe et le bouton Change password.
  1. Ouvre la page d'installation — APP_URL/setup, l'adresse utilisée pour cette instance.
  2. Colle le jeton d'installation : SETUP_TOKEN dans le fichier d'environnement de la pile, ou la valeur que le conteneur en cours d'exécution affiche avec la commande ci-dessous.
  3. Saisis l'e-mail du compte et un nouveau mot de passe, puis choisis Change password. Il n'y a pas d'étape de confirmation et rien n'est envoyé — le nouveau mot de passe fonctionne à la prochaine connexion.
docker compose exec app printenv Setup__Token

Toutes les sessions de ce compte sont déconnectées, car la réinitialisation révoque ses jetons de rafraîchissement. Rien d'autre ne change sur l'instance.

Les membres ne peuvent pas le faire eux-mêmes. La page de connexion le dit au lieu de proposer un lien de réinitialisation : un membre qui a oublié son mot de passe te le demande.

Sauvegardes

Arrête d'abord la pile, pour que la copie ne soit pas prise en pleine écriture.

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

Le .env contient les clés qui rendent les volumes lisibles : une copie sans lui n'est pas une sauvegarde. Pour restaurer : recrée le dossier, mets ton propre .env en place avant le premier démarrage, décompresse les archives dans les mêmes noms de volumes, et démarre.

Dépannage

L'app redémarre deux ou trois fois au premier démarragePostgreSQL s'initialise encore et refuse les connexions. La politique de redémarrage ramène l'app dès qu'il est prêt. Cela n'arrive qu'une fois, sur une installation neuve.
/health signale objectStorage comme unhealthyLe stockage n'est pas encore démarré, ou son bucket n'a pas pu être créé. Vérifie les identifiants et les points d'accès.
Les photos s'envoient depuis le serveur mais pas depuis un navigateurSTORAGE_PUBLIC_ENDPOINT n'est pas joignable depuis le navigateur, ou le port du stockage n'est pas publié.
Tout le monde est déconnecté après un redémarrageLe volume data_protection_keys a disparu ou a été recréé.
La page d'installation dit que le jeton est fauxLa valeur n'est pas le SETUP_TOKEN du .env.

Remarques

ÉditionPas de facturation, pas de panneau opérateur et pas de pages marketing. Ils ne sont pas du tout dans cette image, et la construction échoue si l'un d'eux revient.
LimitesAucun quota de stockage et aucune limite de membres : aucune formule ne les limite.
LicenceGratuit pour votre usage personnel ou au sein de votre organisation, sur autant d'instances que vous voulez. Pas gratuit à revendre, ni à proposer à d'autres comme service hébergé — cela demande une licence commerciale. Les conditions complètes accompagnent l'image sous le nom LICENSE.
E-mailAucun. Cette édition n'envoie rien du tout : les invitations sont des liens que tu copies depuis la page des membres et que tu envoies toi-même, et un mot de passe se réinitialise depuis la page d'installation avec le jeton d'installation.
ImageL'image auto-hébergée est publiée séparément de celle du service hébergé, parce que la construction hébergée embarque les prestataires de paiement et le panneau opérateur.
Memory Atlas

Memory Atlas

🇪🇺 Conçu et hébergé en Europe

© 2026 Memory Atlas

Cette page existe aussi en anglais.

English