Memory Atlas, self-hosted

Memory Atlas on hardware you control, as three containers: the app, PostgreSQL and object storage. The same application as the hosted service, without billing, the operator panel or the marketing pages.

Image on Docker Hub
The setup page of a new instance: a setup token field, then name, email and password.

What you need

  • Docker with Compose v2 — the docker compose subcommand, not the old docker-compose. The whole stack runs in containers.
  • A 64-bit machine — linux/amd64 or linux/arm64. Both are published: an x86-64 server, or an arm64 board (a Raspberry Pi 4 or 5 on a 64-bit OS, an ARM NAS, Apple silicon). 32-bit ARM is not published.
  • A machine that stays on, because it is the server. A spare computer, a NAS or a small VPS all work.
  • About 3 GB of disk to start. The app image is 1.7 GB; photos and videos fill the rest.

No domain, database or storage account is required, and there is nothing to configure for mail. HTTPS is the one thing that can be added later.

Version tags

stableFollows the newest release, and moves when you pull.
<version>A released version, pinned. Use one to decide for yourself when this instance moves.
sha-<commit>The exact commit an image was built from.

Usage

Both files go in a directory of their own. Compose reads compose.yaml and .env by name, so no command has to name either of them.

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

Generate the 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

Start it

docker compose up -d

The first start takes about a minute: the app applies its database migrations and creates its storage bucket. Then open the address in APP_URL — until the first account exists, every address on the instance leads to the setup page.

Keep SETUP_TOKEN somewhere other than the server. It creates the first account, and it is the only way a password is reset here — this edition sends no email, so there is no reset link to wait for. If it goes missing, the container still has it — the Passwords section below has the command that prints it.

Parameters

Ports are written host:container, so 8080:8080 publishes the app's port 8080 on port 8080 of the host. Everything else is an environment variable, read from the .env beside compose.yaml.

Ports

8080:8080Web interface and API.
8333:8333Object storage. The browser uploads straight here, so this port has to be reachable from the browser. With HTTPS added below, Caddy serves storage from the app's own origin instead.

Environment

Only the required group has to be in .env. The compose file already passes everything else with the value shown; add one to .env to change it.

Required

APP_URLThe address people reach this instance on. Every invitation link is built from it.
SETUP_TOKENAuthorises first-run setup, and password resets afterwards. Sixteen characters or more.
DB_PASSWORDPassword of the bundled PostgreSQL. Only used inside the stack, and fixed into the database the first time it is created.
JWT_SECRETSigns every session. 32 characters or more; changing it signs everybody out.
STORAGE_ACCESS_KEY STORAGE_SECRET_KEYCredentials of the bundled object storage. They sign every upload and download.
STORAGE_PUBLIC_ENDPOINTThe address the browser uses for object storage — not the address the app uses. host:port for plain HTTP, or the scheme as well (https://<domain>) once HTTPS is added below.

Set by the stack

APP_PORTThe host port the app is published on. Change it if 8080 is taken.
STORAGE_PORTThe host port the bundled storage is published on. Change it if 8333 is taken.
STORAGE_ENDPOINTWhere the app reaches storage inside the stack: storage:8333, the storage service. Point it elsewhere to use storage you already have.
STORAGE_USE_SSLfalse for the bundled storage, which speaks plain HTTP. true for a provider that serves TLS.
STORAGE_BUCKET_NAMEThe bucket the app keeps its objects in. Created on first start. Changing it later hides the photos that are already there.
AUTH_RATE_LIMITSign-in and setup attempts per address per minute, above the app's own default because first-run setup is several guarded calls in a row by one person.

Optional

DOMAINOnly read by the HTTPS service below, where it is the certificate's name.

Any setting in the app's own configuration can be set the same way, with two underscores for each colon: Upload__MaxVideoSizeMB=2048. Add it to the environment: block of the app service.

Volumes

postgres_dataEvery place, visit, photo record and account.
storage_dataThe photos and videos themselves.
data_protection_keysData protection keys. Losing them signs everyone out; no data is lost.

Optional: HTTPS with a domain

Caddy gets and renews the certificate itself, and serves object storage from the same origin — a browser on an https page will not upload to an http address. Add this service to compose.yaml, and the two volumes it uses to the list at the end of that file:

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:

Save the Caddyfile beside compose.yaml, unchanged. It sends the bucket path to storage and everything else to the app, stops the upload timeouts from cutting off a large video, and keeps the instance out of search engines:

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

Then set DOMAIN to the domain, and APP_URL and STORAGE_PUBLIC_ENDPOINT to https://<domain> — with the scheme, without the port. DNS for the domain has to point at this machine already, and ports 80 and 443 have to be reachable, because Caddy asks Let's Encrypt for the certificate on first start. Start the stack again:

docker compose up -d

Updating

The secrets in .env and the three volumes survive. Database migrations run at startup, so there is no migration step.

docker compose pull && docker compose up -d

Passwords

This edition sends no email, so there is no reset link to wait for: a password is changed at /setup, with the setup token. It is the same page that created the first account, and it keeps working after the instance is set up.

The setup page of an instance that is already set up: the setup token field, then the account's email, a new password and the Change password button.
  1. Open the setup page — APP_URL/setup, the address people use for this instance.
  2. Paste the setup token: SETUP_TOKEN in the stack's env file, or the value the running container prints with the command below.
  3. Enter the account's email and a new password, then choose Change password. There is no confirmation step and nothing is sent — the new password works at the next sign-in.
docker compose exec app printenv Setup__Token

Everyone signed in to that account is signed out, because the reset revokes its refresh tokens. Nothing else on the instance changes.

Members cannot do this for themselves. The login page says so instead of offering a reset link, so somebody who has forgotten their password asks you.

Backups

Stop the stack first, so the copy is not taken mid-write.

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

.env holds the keys that make the volumes readable, so a copy without it is not a backup. To restore: recreate the directory, put your own .env in place before the first start, unpack the archives into the same volume names, and start it.

Troubleshooting

The app restarts a couple of times on the first startPostgreSQL is still initialising and refuses connections. The restart policy brings the app back once it is up. It happens once, on a fresh installation.
/health reports objectStorage as unhealthyStorage is not up yet, or its bucket could not be created. Check the credentials and the endpoints.
Photos upload from the server but not from a browserSTORAGE_PUBLIC_ENDPOINT is not reachable from the browser, or the storage port is not published.
Everybody is signed out after a restartThe data_protection_keys volume is missing or was recreated.
The setup page says the token is wrongThe value is not the SETUP_TOKEN that is in .env.

Notes

EditionNo billing, no operator panel and no marketing pages. They are not in this image at all, and the build fails if one of them comes back.
LimitsNo storage quota and no member cap: there is no plan to limit them against.
LicenceFree to run for yourself or inside your own organisation, on as many instances as you like. Not free to resell, and not free to offer to others as a hosted service — that needs a commercial licence. The full terms ship with the image as LICENSE.
EmailNone. This edition sends nothing at all: invitations are links you copy from the Members page and send yourself, and a password is reset from the setup page with the setup token.
ImageThe self-hosted image is published separately from the hosted one, because the hosted build carries the billing providers and the operator panel.
Memory Atlas

Memory Atlas

🇪🇺 Built & hosted in Europe

© 2026 Memory Atlas