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.

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.
| stable | Follows 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. |
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
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
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.
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.
| 8080:8080 | Web interface and API. |
| 8333:8333 | Object 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. |
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_URL | The address people reach this instance on. Every invitation link is built from it. |
| SETUP_TOKEN | Authorises first-run setup, and password resets afterwards. Sixteen characters or more. |
| DB_PASSWORD | Password of the bundled PostgreSQL. Only used inside the stack, and fixed into the database the first time it is created. |
| JWT_SECRET | Signs every session. 32 characters or more; changing it signs everybody out. |
| STORAGE_ACCESS_KEY STORAGE_SECRET_KEY | Credentials of the bundled object storage. They sign every upload and download. |
| STORAGE_PUBLIC_ENDPOINT | The 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_PORT | The host port the app is published on. Change it if 8080 is taken. |
| STORAGE_PORT | The host port the bundled storage is published on. Change it if 8333 is taken. |
| STORAGE_ENDPOINT | Where the app reaches storage inside the stack: storage:8333, the storage service. Point it elsewhere to use storage you already have. |
| STORAGE_USE_SSL | false for the bundled storage, which speaks plain HTTP. true for a provider that serves TLS. |
| STORAGE_BUCKET_NAME | The bucket the app keeps its objects in. Created on first start. Changing it later hides the photos that are already there. |
| AUTH_RATE_LIMIT | Sign-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
| DOMAIN | Only 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.
| postgres_data | Every place, visit, photo record and account. |
| storage_data | The photos and videos themselves. |
| data_protection_keys | Data protection keys. Losing them signs everyone out; no data is lost. |
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
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
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.

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.
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.
| The app restarts a couple of times on the first start | PostgreSQL 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 unhealthy | Storage 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 browser | STORAGE_PUBLIC_ENDPOINT is not reachable from the browser, or the storage port is not published. |
| Everybody is signed out after a restart | The data_protection_keys volume is missing or was recreated. |
| The setup page says the token is wrong | The value is not the SETUP_TOKEN that is in .env. |
| Edition | No 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. |
| Limits | No storage quota and no member cap: there is no plan to limit them against. |
| Licence | Free 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. |
| None. 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. | |
| Image | The self-hosted image is published separately from the hosted one, because the hosted build carries the billing providers and the operator panel. |
Memory Atlas
🇪🇺 Built & hosted in Europe
© 2026 Memory Atlas