Memory Atlas, selbst gehostet

Memory Atlas auf Hardware, die dir gehört, als drei Container: App, PostgreSQL und Objektspeicher. Dieselbe Anwendung wie der gehostete Dienst, ohne Abrechnung, ohne Betreiber-Oberfläche und ohne Marketingseiten.

Image auf Docker Hub
Die Einrichtungsseite einer neuen Instanz: ein Feld für das Setup-Token, darunter Name, E-Mail und Passwort.

Was du brauchst

  • Docker mit Compose v2 — der Unterbefehl docker compose, nicht das alte docker-compose. Der ganze Stack läuft in Containern.
  • Ein 64-Bit-Rechner — linux/amd64 oder linux/arm64. Beides wird veröffentlicht: ein x86-64-Server oder ein arm64-Board (Raspberry Pi 4 oder 5 mit 64-Bit-Betriebssystem, ein ARM-NAS, Apple Silicon). 32-Bit-ARM gibt es nicht.
  • Eine Maschine, die an bleibt, denn sie ist der Server. Ein übriger Rechner, ein NAS oder ein kleiner VPS reichen.
  • Etwa 3 GB Speicherplatz zum Start. Das App-Image ist 1,7 GB groß; den Rest füllen Fotos und Videos.

Domain, Datenbank und Speicherkonto sind nicht nötig, und für E-Mail gibt es nichts einzurichten. HTTPS ist das Einzige, was später dazukommen kann.

Versions-Tags

stableFolgt der neuesten Veröffentlichung und zieht mit, wenn du pullst.
<version>Eine feste Version. Damit entscheidest du selbst, wann diese Instanz weiterzieht.
sha-<commit>Der genaue Commit, aus dem ein Image gebaut wurde.

Verwendung

Beide Dateien kommen in ein eigenes Verzeichnis. Compose liest compose.yaml und .env von selbst, deshalb muss kein Befehl sie nennen.

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

Geheimnisse erzeugen

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

Starten

docker compose up -d

Der erste Start dauert etwa eine Minute: Die App wendet ihre Datenbank-Migrationen an und legt ihren Speicher-Bucket an. Öffne dann die Adresse aus APP_URL — solange kein erstes Konto existiert, führt jede Adresse der Instanz zur Einrichtungsseite.

Bewahre SETUP_TOKEN außerhalb des Servers auf. Es legt das erste Konto an und ist der einzige Weg, hier ein Passwort zurückzusetzen — diese Edition verschickt keine E-Mail, es gibt also keinen Link, auf den man warten könnte. Geht es verloren, hat der Container es noch — der Befehl dafür steht im Abschnitt "Passwörter" weiter unten.

Parameter

Ports werden als host:container geschrieben: 8080:8080 veröffentlicht Port 8080 der App auf Port 8080 des Hosts. Alles andere ist eine Umgebungsvariable aus der .env neben compose.yaml.

Ports

8080:8080Weboberfläche und API.
8333:8333Objektspeicher. Der Browser lädt direkt hierher hoch, deshalb muss dieser Port aus dem Browser erreichbar sein. Mit HTTPS weiter unten liefert Caddy den Speicher stattdessen über die Herkunft der App aus.

Umgebungsvariablen

Nur die Gruppe „Erforderlich“ muss in der .env stehen. Alles andere übergibt die Compose-Datei bereits mit dem gezeigten Wert; zum Ändern den Eintrag in die .env aufnehmen.

Erforderlich

APP_URLDie Adresse, unter der Menschen diese Instanz erreichen. Jeder Einladungslink wird daraus gebaut.
SETUP_TOKENBerechtigt zur ersten Einrichtung und danach zum Zurücksetzen von Passwörtern. Sechzehn Zeichen oder mehr.
DB_PASSWORDPasswort der mitgelieferten PostgreSQL. Wird nur innerhalb des Stacks verwendet und beim ersten Anlegen in die Datenbank geschrieben.
JWT_SECRETSigniert jede Sitzung. 32 Zeichen oder mehr; eine Änderung meldet alle ab.
STORAGE_ACCESS_KEY STORAGE_SECRET_KEYZugangsdaten des mitgelieferten Objektspeichers. Sie signieren jeden Upload und Download.
STORAGE_PUBLIC_ENDPOINTDie Adresse, die der Browser für den Objektspeicher nutzt — nicht die Adresse, die die App nutzt. host:port bei einfachem HTTP, oder mit Protokoll (https://<domain>), sobald HTTPS weiter unten hinzukommt.

Vom Stack gesetzt

APP_PORTDer Host-Port, auf dem die App veröffentlicht wird. Ändere ihn, wenn 8080 belegt ist.
STORAGE_PORTDer Host-Port, auf dem der mitgelieferte Speicher veröffentlicht wird. Ändere ihn, wenn 8333 belegt ist.
STORAGE_ENDPOINTWo die App den Speicher innerhalb des Stacks erreicht: storage:8333, der Speicher-Dienst. Richte ihn woanders hin, um eigenen Speicher zu verwenden.
STORAGE_USE_SSLfalse für den mitgelieferten Speicher, der einfaches HTTP spricht. true für einen Anbieter, der TLS ausliefert.
STORAGE_BUCKET_NAMEDer Bucket, in dem die App ihre Objekte hält. Wird beim ersten Start angelegt. Eine spätere Änderung versteckt die bereits vorhandenen Fotos.
AUTH_RATE_LIMITAnmelde- und Einrichtungsversuche pro Adresse und Minute, höher als der eigene Standard der App, weil die erste Einrichtung mehrere geschützte Aufrufe einer Person hintereinander sind.

Optional

DOMAINWird nur vom HTTPS-Dienst weiter unten gelesen, wo er der Name des Zertifikats ist.

Jede Einstellung der App-Konfiguration lässt sich genauso setzen, mit zwei Unterstrichen für jeden Doppelpunkt: Upload__MaxVideoSizeMB=2048. Füge sie dem environment:-Block des app-Dienstes hinzu.

Volumes

postgres_dataJeder Ort, jeder Besuch, jeder Fotoeintrag und jedes Konto.
storage_dataDie Fotos und Videos selbst.
data_protection_keysSchlüssel zum Schutz der Daten. Gehen sie verloren, werden alle abgemeldet; Daten gehen keine verloren.

Optional: HTTPS mit Domain

Caddy holt und erneuert das Zertifikat selbst und liefert den Objektspeicher über dieselbe Herkunft aus — ein Browser auf einer https-Seite lädt nicht auf eine http-Adresse hoch. Füge diesen Dienst der compose.yaml hinzu, und die beiden Volumes, die er nutzt, der Liste am Ende derselben Datei:

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:

Lege die Caddyfile unverändert neben die compose.yaml. Sie schickt den Bucket-Pfad an den Objektspeicher und alles andere an die App, verhindert, dass die Upload-Zeitlimits ein großes Video abschneiden, und hält die Instanz aus Suchmaschinen heraus:

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

Setze dann DOMAIN auf die Domain und APP_URL sowie STORAGE_PUBLIC_ENDPOINT auf https://<domain> — mit Protokoll, ohne Port. Das DNS der Domain muss bereits auf diese Maschine zeigen und die Ports 80 und 443 erreichbar sein, weil Caddy beim ersten Start Let's Encrypt nach dem Zertifikat fragt. Starte den Stack erneut:

docker compose up -d

Aktualisieren

Die Geheimnisse in der .env und die drei Volumes überleben. Datenbank-Migrationen laufen beim Start, es gibt also keinen eigenen Migrationsschritt.

docker compose pull && docker compose up -d

Passwörter

Diese Edition verschickt keine E-Mail, es gibt also keinen Zurücksetzen-Link, auf den man warten könnte: Ein Passwort wird unter /setup geändert, mit dem Setup-Token. Das ist dieselbe Seite, die das erste Konto angelegt hat, und sie funktioniert auch nach der Einrichtung weiter.

Die Einrichtungsseite einer bereits eingerichteten Instanz: Feld für das Setup-Token, darunter die E-Mail des Kontos, ein neues Passwort und die Schaltfläche Change password.
  1. Die Einrichtungsseite öffnen — APP_URL/setup, die Adresse, unter der diese Instanz erreichbar ist.
  2. Das Setup-Token einfügen: SETUP_TOKEN in der Env-Datei des Stacks, oder der Wert, den der laufende Container mit dem Befehl unten ausgibt.
  3. E-Mail des Kontos und ein neues Passwort eingeben, dann Change password wählen. Es gibt keinen Bestätigungsschritt und es wird nichts verschickt — das neue Passwort gilt bei der nächsten Anmeldung.
docker compose exec app printenv Setup__Token

Alle Sitzungen dieses Kontos werden abgemeldet, weil das Zurücksetzen die Refresh-Tokens widerruft. Sonst ändert sich nichts an der Instanz.

Mitglieder können das nicht selbst. Die Anmeldeseite sagt das, statt einen Zurücksetzen-Link anzubieten — wer sein Passwort vergessen hat, fragt dich.

Sicherungen

Stoppe den Stack vorher, damit die Kopie nicht mitten im Schreiben entsteht.

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

In der .env stehen die Schlüssel, die die Volumes lesbar machen — eine Kopie ohne sie ist keine Sicherung. Zum Wiederherstellen: Lege das Verzeichnis neu an, setze deine eigene .env vor dem ersten Start ein, entpacke die Archive unter denselben Volume-Namen und starte.

Fehlersuche

Die App startet beim ersten Start ein paar Mal neuPostgreSQL initialisiert noch und lehnt Verbindungen ab. Die Neustart-Richtlinie holt die App zurück, sobald er läuft. Das passiert einmalig bei einer frischen Installation.
/health meldet objectStorage als unhealthyDer Speicher ist noch nicht oben, oder sein Bucket konnte nicht angelegt werden. Prüfe Zugangsdaten und Endpunkte.
Fotos laden vom Server, aber nicht aus dem Browser hochSTORAGE_PUBLIC_ENDPOINT ist aus dem Browser nicht erreichbar, oder der Speicher-Port ist nicht veröffentlicht.
Nach einem Neustart sind alle abgemeldetDas Volume data_protection_keys fehlt oder wurde neu angelegt.
Die Einrichtungsseite meldet, das Token sei falschDer Wert ist nicht das SETUP_TOKEN aus der .env.

Hinweise

EditionKeine Abrechnung, keine Betreiber-Oberfläche und keine Marketingseiten. Sie sind gar nicht erst in diesem Image, und der Build schlägt fehl, wenn eines davon zurückkehrt.
GrenzenKein Speicherkontingent und keine Mitgliedergrenze: Es gibt keinen Tarif, der sie begrenzen würde.
LizenzKostenlos für den eigenen Gebrauch oder innerhalb der eigenen Organisation, auf beliebig vielen Instanzen. Nicht kostenlos weiterverkaufen und nicht kostenlos anderen als gehosteten Dienst anbieten — dafür braucht es eine kommerzielle Lizenz. Die vollständigen Bedingungen liegen dem Image als LICENSE bei.
E-MailKeine. Diese Edition verschickt gar nichts: Einladungen sind Links, die du von der Mitgliederseite kopierst und selbst verschickst, und ein Passwort wird auf der Einrichtungsseite mit dem Setup-Token zurückgesetzt.
ImageDas selbst gehostete Image wird getrennt vom gehosteten veröffentlicht, weil der gehostete Build die Zahlungsanbieter und die Betreiber-Oberfläche enthält.
Memory Atlas

Memory Atlas

🇪🇺 In Europa entwickelt und gehostet

© 2026 Memory Atlas

Diese Seite gibt es auch auf Englisch.

English