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.

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.
| stable | Folgt 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. |
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
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
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.
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.
| 8080:8080 | Weboberfläche und API. |
| 8333:8333 | Objektspeicher. 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. |
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_URL | Die Adresse, unter der Menschen diese Instanz erreichen. Jeder Einladungslink wird daraus gebaut. |
| SETUP_TOKEN | Berechtigt zur ersten Einrichtung und danach zum Zurücksetzen von Passwörtern. Sechzehn Zeichen oder mehr. |
| DB_PASSWORD | Passwort der mitgelieferten PostgreSQL. Wird nur innerhalb des Stacks verwendet und beim ersten Anlegen in die Datenbank geschrieben. |
| JWT_SECRET | Signiert jede Sitzung. 32 Zeichen oder mehr; eine Änderung meldet alle ab. |
| STORAGE_ACCESS_KEY STORAGE_SECRET_KEY | Zugangsdaten des mitgelieferten Objektspeichers. Sie signieren jeden Upload und Download. |
| STORAGE_PUBLIC_ENDPOINT | Die 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_PORT | Der Host-Port, auf dem die App veröffentlicht wird. Ändere ihn, wenn 8080 belegt ist. |
| STORAGE_PORT | Der Host-Port, auf dem der mitgelieferte Speicher veröffentlicht wird. Ändere ihn, wenn 8333 belegt ist. |
| STORAGE_ENDPOINT | Wo die App den Speicher innerhalb des Stacks erreicht: storage:8333, der Speicher-Dienst. Richte ihn woanders hin, um eigenen Speicher zu verwenden. |
| STORAGE_USE_SSL | false für den mitgelieferten Speicher, der einfaches HTTP spricht. true für einen Anbieter, der TLS ausliefert. |
| STORAGE_BUCKET_NAME | Der 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_LIMIT | Anmelde- 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
| DOMAIN | Wird 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.
| postgres_data | Jeder Ort, jeder Besuch, jeder Fotoeintrag und jedes Konto. |
| storage_data | Die Fotos und Videos selbst. |
| data_protection_keys | Schlüssel zum Schutz der Daten. Gehen sie verloren, werden alle abgemeldet; Daten gehen keine verloren. |
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
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
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.

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.
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.
| Die App startet beim ersten Start ein paar Mal neu | PostgreSQL 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 unhealthy | Der 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 hoch | STORAGE_PUBLIC_ENDPOINT ist aus dem Browser nicht erreichbar, oder der Speicher-Port ist nicht veröffentlicht. |
| Nach einem Neustart sind alle abgemeldet | Das Volume data_protection_keys fehlt oder wurde neu angelegt. |
| Die Einrichtungsseite meldet, das Token sei falsch | Der Wert ist nicht das SETUP_TOKEN aus der .env. |
| Edition | Keine 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. |
| Grenzen | Kein Speicherkontingent und keine Mitgliedergrenze: Es gibt keinen Tarif, der sie begrenzen würde. |
| Lizenz | Kostenlos 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. |
| Keine. 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. | |
| Image | Das selbst gehostete Image wird getrennt vom gehosteten veröffentlicht, weil der gehostete Build die Zahlungsanbieter und die Betreiber-Oberfläche enthält. |
Memory Atlas
🇪🇺 In Europa entwickelt und gehostet
© 2026 Memory Atlas
Diese Seite gibt es auch auf Englisch.
English