Can I self-host Day One?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Memos. It takes one prompt, a 512 MB VPS, and about 9 minutes. That is $8.99 a month you stop paying Day One — $107.88 a year on the Silver plan.

Why people pay for Day One

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Day One sells the parts of keeping a journal that are hard to build and easy to abandon. First-party apps on iPhone, iPad, Mac and Android that open fast enough to catch a thought before it goes. End-to-end encrypted sync between them, so the entries are on every device and readable on none of the company's servers. On-this-day resurfacing that turns five years of entries into something you actually reread. And a printing service that ends the year as a hardcover book on a shelf. Automattic runs the sync, the apps and the press, and that operation is what the subscription buys.

Day One plans and list prices
PlanList priceWhat it buys
BasicfreeFree. Unlimited entries and journals on one device, one photo per entry, end-to-end encryption, daily prompts and templates. Sync between devices is not included.
Silverthe plan this page prices against$8.99/moSync across devices, up to 30 media attachments per entry, audio with transcription, PDF scanning, Strava and Zapier integrations. dayoneapp.com/pricing sells it only as $49.99 a year, which works out to $4.17 a month if you pay twelve up front; $8.99 is the month-to-month price on the US App Store listing. This tier was called Premium until March 2026.
Gold$6.25/moEverything in Silver plus the AI features: Daily Chat, entry summaries, title suggestions and image generation, and a bigger discount on printed journals. $74.99 a year is the only figure the vendor publishes for it; $6.25 is that number divided by twelve, not a monthly plan you can buy.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by Memos

One project, named before the prompt, so you know what you are about to install.

A private capture stream for the thought you would otherwise lose: markdown entries, tags and a timeline, from one container.

The right answer to the narrower question, said plainly. Memos does not replace Day One's apps, its encrypted sync or its printing, and no self-hosted project does; it replaces the daily capture loop and the timeline you scroll back through. One container, one SQLite file, markdown entries with tags and photos, and a private instance that answers a stranger with a sign-in page. What you give up is real: the server reads every entry, there is no first-party phone app, and the third-party iOS and Android client does not speak this release yet, so on a phone this is a web page saved to the home screen. What you get back is a journal nobody can price, rename or discontinue, on a disk you can hold.

The swap

You're paying

Day One

$8.99/mo · $107.88/yr

is replaced by

You'd run

Memos

ONE COMMAND · ~9 min to running · 512 MB RAM

Day One Silver · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~9 minunder 10 minutes, through the first backup

The prompt

Two paths to the same Memos: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

311 lines · 14,144 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Memos 0.30.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say two things to the user before anything installs, because together they decide whether they
want this at all. Memos is a capture stream: short markdown notes with tags, newest first, read
and written in a browser. There is no first-party phone app, and the third-party ones do not
speak this release yet, so on a phone this is a web page saved to the home screen. And nothing
here is end-to-end encrypted: every entry sits in a SQLite file this server can read, and from
today they are the person who runs that server.

Memos needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and arm64.
Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a hostname that does not resolve.

## 2. Layout

Three directories and one configuration file. That file is the security decision in this install,
so it is written before the container has ever run.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/memos /srv/memos/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/memos/config
sudo install -d -m 750 -o 10001 -g 10001 /srv/memos/data
cat > /srv/memos/config/memos-instance-setting-general.json <<'EOF'
{
  "key": "GENERAL",
  "generalSetting": {
    "disallowUserRegistration": true
  }
}
EOF
chmod 644 /srv/memos/config/memos-instance-setting-general.json
ls -la /srv/memos /srv/memos/config
```

Assert: `ls -la` shows `data` owned by uid `10001`, `config` at mode `755`, and the JSON file at
mode `644`. Memos runs as uid 10001 and reads that file at start-up, so it is world-readable on
purpose: the container user is not the login user, and the file holds a policy flag rather than
a credential. Upstream scans /etc/secrets once per process for filenames of exactly this shape,
which means an edit here takes effect on the next restart and at no other time.

## 3. Secrets

No secret is generated for this install and there is no `.env` file. Memos keeps its own session
key inside its database, and the only credential a human ever types is the administrator account
created in a browser at step 7.

That is also why step 2 ran first. Most first-run installs leave registration open between the
container starting and a human claiming the account, and close it afterwards.
`disallowUserRegistration` is already on here before the first request arrives, and the very
first account still gets through, because an instance with zero users takes the setup path
rather than the registration path. Step 7 asserts both halves of that.

Tell the user now: after step 7 nobody can sign themselves up on this server, and the way to add
a second person is to create the account for them from the administrator settings.

## 4. compose.yml

```bash
cat > /srv/memos/compose.yml <<'EOF'
# Memos · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://www.usememos.com/docs/deploy/docker-compose
#   configuration ........ https://www.usememos.com/docs/configuration/environment-variables
#   security ............. https://www.usememos.com/docs/configuration/security
#   provisioning ......... https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# One service and one SQLite file. There is no `user:` line on purpose: the
# image entrypoint starts as root, hands /var/opt/memos to uid 10001 and
# re-execs as that user, so pinning a uid here would undo the fix it performs
# for you. MEMOS_INSTANCE_URL is deliberately absent, because upstream treats an
# instance without one as private and limits anonymous callers to the sign-in
# endpoints. The read-only /etc/secrets bind carries one deployment
# configuration file, written in step 2, that turns self-registration off before
# the first request is ever served. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  memos:
    image: neosmemo/memos:0.30.0@sha256:71a5b4738d1bed96e92112004054f0888e92791b64eb78afd79077c96e6f9327
    container_name: memos
    restart: unless-stopped
    environment:
      MEMOS_PORT: "5230"
      MEMOS_DATA: /var/opt/memos
      MEMOS_DRIVER: sqlite
      # No MEMOS_INSTANCE_URL here. Empty means private, and private means an
      # anonymous visitor gets the sign-in page and nothing else.
    volumes:
      # memos_prod.db plus the assets/ folder that attachments land in.
      - /srv/memos/data:/var/opt/memos
      # Deployment configuration, read once at start-up and never written to.
      - type: bind
        source: /srv/memos/config
        target: /etc/secrets
        read_only: true
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8131.
      - "127.0.0.1:8131:5230"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5230/healthz"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd /srv/memos && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no database container: Memos
writes everything to `data/memos_prod.db` and puts uploaded photos beside it in `data/assets`.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-memos
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Memos · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.usememos.com/docs/deploy/reverse-proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Memos runs its own
# HTTP server and upstream still asks you to put a proxy in front of it that
# terminates TLS. This is that proxy.

<DOMAIN> {
	# The app bundle and the JSON API compress well. Caddy's default encode
	# matcher covers text, JSON, JavaScript and SVG only, so a photo attached
	# to an entry passes through untouched.
	encode zstd gzip

	# Memos sets no frame or transport headers of its own on the app routes,
	# so they are set here. HSTS is on because every request to this host
	# carries the session cookie for somebody's journal.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8131 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a 30 MB attachment upload gets through,
	# and it flushes text/event-stream as it arrives, which is what the live
	# timeline updates ride on.
	reverse_proxy 127.0.0.1:8131
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-memos, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it on its own, so there is nothing
to schedule.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8131 stays closed because compose binds it to 127.0.0.1 and Caddy is the only
thing that speaks to it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp
and 443/udp, and no rule mentioning 8131 or 5230.

## 7. Start and verify

```bash
cd /srv/memos
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sS https://<DOMAIN>/api/v1/instance/profile; echo
curl -sS https://<DOMAIN>/api/v1/instance/settings/GENERAL; echo
```

Assert all four, and print what you received for each: the loop ends printing `200`; the health
endpoint answers `Service ready.`; the profile JSON contains `"version":"0.30.0"` and
`"needsSetup":true`; the settings JSON contains `"disallowUserRegistration":true`. Those last two
together are the security assert in this block, and the order is the point: registration is shut
and no account exists yet. If the loop never reaches 200, stop, run
`docker compose logs --tail 40 memos`, and say which earlier step is the likely cause: a
container that exits immediately is usually step 2, because a malformed file under that mount
makes Memos refuse to start rather than ignore it, and a 502 from Caddy with a healthy container
is step 5. A running container is not success.

STOP: tell the user to open https://<DOMAIN>/auth/signup, create their account, put the password
in their password manager, and wait. Do not continue until they confirm. That exact path matters:
the sign-in page at https://<DOMAIN> carries no sign-up link, because step 2 turned registration
off. The screen at /auth/signup reads `Set up your instance` above
`Create the administrator account for this instance.`, with a `First run` badge and a
`Create admin account` button.

Once they confirm:

```bash
curl -sS https://<DOMAIN>/api/v1/instance/profile; echo
```

Assert: the response now contains `"admin":` and no longer contains `"needsSetup":true`. That is
the setup path closed behind them, and it is the other half of the security assert. If it still
prints `"needsSetup":true`, the account was not created and the server is still claimable; do
not go on.

## 8. First backup and restore

One archive: the database, the photos, the deployment configuration and the live Caddy site
block. Take it now, before the user writes anything they would miss.

```bash
cd /srv/memos
docker compose stop
sudo tar -czf /srv/memos/backups/memos-$(date +%F).tar.gz -C /srv/memos data config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/memos/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds, and
the container is stopped on purpose, because a SQLite database copied mid-write is not a backup.

A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:

```bash
mkdir -p ~/backups/memos
scp vps:/srv/memos/backups/*.tar.gz ~/backups/memos/
```

To restore: `docker compose down`, `sudo rm -rf /srv/memos/data`, recreate the directories as in
step 2, untar the archive back into /srv/memos, put the Caddy block back if that is what was
lost, then `docker compose up -d`. Entries, tags and accounts live in `data/memos_prod.db`, and
attached photos are ordinary files under `data/assets`, so a single entry can be recovered from
the archive with `sqlite3` and a copy command if the whole restore is more than the user needs.

## 9. Updating later

New versions are listed at https://github.com/usememos/memos/releases. Take a backup first, then
edit the image line in /srv/memos/compose.yml to the new tag and its digest. The Docker Hub tag
drops the leading `v`, so release `v0.31.0` is image tag `0.31.0`.

```bash
cd /srv/memos
docker compose pull
docker compose up -d
docker compose logs --tail 30 memos
```

Memos migrates its own database on the way up. Watch that log until it settles, then re-run the
`/healthz` and profile checks from step 7 before calling the update done.

## 10. What will probably go wrong

You will open https://<DOMAIN> after step 7 starts the container, land on a sign-in page with a
username box, a password box and no way to make an account, and conclude something is broken. I
did, and I spent ten minutes re-reading the compose file. Nothing was wrong: closing registration
in step 2 also removes the sign-up link from the sign-in page, and the first-run form lives at
https://<DOMAIN>/auth/signup whether or not anything links to it. Go straight to that path. Do
not switch `disallowUserRegistration` back to false to make the link reappear; that reopens the
server to anyone who finds the hostname.

## 11. Out of scope

- Do not set `MEMOS_INSTANCE_URL`. Upstream uses it as the switch for anonymous public access,
  and this install is a private journal that answers strangers with a sign-in page.
- Do not switch `MEMOS_DRIVER` to postgres or mysql. SQLite is the choice here, and it is what
  makes this one container and one file to copy.
- Do not configure SMTP, an S3 bucket or an AI provider in the instance settings. Each is an
  account somewhere else, and the file written in step 2 owns the general settings group only.
- Do not install the Telegram integration or the web clipper. They are separate upstream
  services with their own containers, and this prompt installs the server they would talk to.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Memos 0.30.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the box.

Two things to know before step 1, because they decide whether you want this at all. Memos is a
capture stream: short markdown notes with tags, newest first, read and written in a browser.
There is no first-party phone app and the third-party ones do not speak this release yet, so on a
phone this is a web page saved to your home screen. And nothing here is end-to-end encrypted:
every entry sits in a SQLite file the server can read, and from today you are the person who runs
that server.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname while the certificate is issued.

## 2. Layout

Three directories and one configuration file. That file is the security decision in this install,
and it is written before the container has ever run, so paste the whole block at once.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/memos /srv/memos/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/memos/config
sudo install -d -m 750 -o 10001 -g 10001 /srv/memos/data
cat > /srv/memos/config/memos-instance-setting-general.json <<'EOF'
{
  "key": "GENERAL",
  "generalSetting": {
    "disallowUserRegistration": true
  }
}
EOF
chmod 644 /srv/memos/config/memos-instance-setting-general.json
ls -la /srv/memos /srv/memos/config
```

You should see: `data` owned by `10001`, `config` at `drwxr-xr-x` owned by you, `backups` owned
by you, and `memos-instance-setting-general.json` at `-rw-r--r--`.

If you do not: leave `data` owned by 10001 on purpose. Memos runs as that uid and writes its
database there. The JSON file is world-readable for the same reason, and that is safe here
because it holds one policy flag and no credential. Upstream reads files of exactly that name
from that mount once per process, so any later edit needs a container restart to mean anything.

## 3. Secrets

There are none to generate, and there is no `.env` file on this server. Memos keeps its own
session key inside its database, and the only credential a human types is the administrator
password you choose in a browser at step 7.

That is what step 2 bought you. Most first-run installs leave registration open between the
container starting and somebody claiming the account, then close it afterwards.
`disallowUserRegistration` is already on before the first request arrives, and the first account
still gets through, because an instance with zero users takes the setup path rather than the
registration path. Step 7 checks both halves of that.

Do not paste your Memos password, any personal access token you create later, or any command
output containing either, into this chat window. Nothing in this install writes a secret to a
file, so there is no file to leak; the password in your head is the whole credential, and a chat
window is a third party.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/memos/compose.yml <<'EOF'
# Memos · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://www.usememos.com/docs/deploy/docker-compose
#   configuration ........ https://www.usememos.com/docs/configuration/environment-variables
#   security ............. https://www.usememos.com/docs/configuration/security
#   provisioning ......... https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# One service and one SQLite file. There is no `user:` line on purpose: the
# image entrypoint starts as root, hands /var/opt/memos to uid 10001 and
# re-execs as that user, so pinning a uid here would undo the fix it performs
# for you. MEMOS_INSTANCE_URL is deliberately absent, because upstream treats an
# instance without one as private and limits anonymous callers to the sign-in
# endpoints. The read-only /etc/secrets bind carries one deployment
# configuration file, written in step 2, that turns self-registration off before
# the first request is ever served. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  memos:
    image: neosmemo/memos:0.30.0@sha256:71a5b4738d1bed96e92112004054f0888e92791b64eb78afd79077c96e6f9327
    container_name: memos
    restart: unless-stopped
    environment:
      MEMOS_PORT: "5230"
      MEMOS_DATA: /var/opt/memos
      MEMOS_DRIVER: sqlite
      # No MEMOS_INSTANCE_URL here. Empty means private, and private means an
      # anonymous visitor gets the sign-in page and nothing else.
    volumes:
      # memos_prod.db plus the assets/ folder that attachments land in.
      - /srv/memos/data:/var/opt/memos
      # Deployment configuration, read once at start-up and never written to.
      - type: bind
        source: /srv/memos/config
        target: /etc/secrets
        read_only: true
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8131.
      - "127.0.0.1:8131:5230"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5230/healthz"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd /srv/memos && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/memos/compose.yml` and paste again in one go. There is no database
container and there is no second service: Memos writes everything to `data/memos_prod.db` and
puts uploaded photos beside it in `data/assets`.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-memos
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Memos · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.usememos.com/docs/deploy/reverse-proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Memos runs its own
# HTTP server and upstream still asks you to put a proxy in front of it that
# terminates TLS. This is that proxy.

<DOMAIN> {
	# The app bundle and the JSON API compress well. Caddy's default encode
	# matcher covers text, JSON, JavaScript and SVG only, so a photo attached
	# to an entry passes through untouched.
	encode zstd gzip

	# Memos sets no frame or transport headers of its own on the app routes,
	# so they are set here. HSTS is on because every request to this host
	# carries the session cookie for somebody's journal.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8131 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a 30 MB attachment upload gets through,
	# and it flushes text/event-stream as it arrives, which is what the live
	# timeline updates ride on.
	reverse_proxy 127.0.0.1:8131
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-memos /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the other.
Caddy requests the certificate on the first request to the hostname and renews it on its own, so
there is nothing to schedule and no cron job to forget.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8131` or `5230`.

If you do not: delete anything for `8131` with `sudo ufw delete allow 8131`. That port is bound
to 127.0.0.1 by the compose file, so a firewall rule for it would cover traffic that cannot
arrive. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3, which Caddy offers by default. `Status: inactive` is a different problem:
Prompt Zero left this firewall enabled, so something has turned it off since, and `sudo ufw
enable` puts it back before you go any further.

## 7. Start and verify

```bash
cd /srv/memos
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sS https://<DOMAIN>/api/v1/instance/profile; echo
curl -sS https://<DOMAIN>/api/v1/instance/settings/GENERAL; echo
```

You should see, in order: the loop reaching `200`; the words `Service ready.`; a small JSON
object containing `"version":"0.30.0"` and `"needsSetup":true`; and a second JSON object
containing `"disallowUserRegistration":true`.

If you do not: those last two lines are the ones worth understanding. `"needsSetup":true` means
no account exists yet, and `"disallowUserRegistration":true` means nobody except the very first
visitor can make one, which is the whole point of step 2. If the loop never reaches `200`, run
`docker compose logs --tail 40 memos`: a container that exits immediately is step 2 done wrong,
because a malformed file under that mount makes Memos refuse to start rather than ignore it. A
`502` from Caddy with a container that stays up is step 5. A running container is not success.

Now claim the account, and go straight to this path rather than to the site root:

```
https://<DOMAIN>/auth/signup
```

You should see: a page headed `Set up your instance` above
`Create the administrator account for this instance.`, with a `First run` badge, a username box,
a password box and a `Create admin account` button. Fill it in, use a password you generated in
your password manager, and save it there before you submit.

If you do not: opening https://<DOMAIN> instead lands you on a sign-in page with no way to make
an account, which looks broken and is not. Closing registration in step 2 also removes the
sign-up link from that page; the first-run form is still at /auth/signup whether or not anything
links to it.

Then prove the setup path shut behind you:

```bash
curl -sS https://<DOMAIN>/api/v1/instance/profile; echo
```

You should see: a JSON object that now contains `"admin":` and no longer contains
`"needsSetup":true`.

If you do not: `"needsSetup":true` still means no account exists and the server is still
claimable by anyone who finds the hostname. Go back to /auth/signup and finish the form before
you do anything else.

## 8. First backup and restore

One archive: the database, the photos, the deployment configuration and the live Caddy site
block. Take it now, before you write anything you would miss.

```bash
cd /srv/memos
docker compose stop
sudo tar -czf /srv/memos/backups/memos-$(date +%F).tar.gz -C /srv/memos data config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/memos/backups/
```

You should see: one `.tar.gz`, a few hundred kilobytes on a fresh install. The site is down for
about five seconds, on purpose: a SQLite database copied mid-write is not a backup.

If you do not: an archive of about 100 bytes means `tar` found nothing, which usually means you
are in the wrong directory. Check `ls /srv/memos` shows `data`, `config` and `compose.yml`.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/memos
scp vps:/srv/memos/backups/*.tar.gz ~/backups/memos/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/memos/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty journal:

```bash
cd /srv/memos
docker compose down
sudo rm -rf /srv/memos/data
sudo install -d -m 750 -o 10001 -g 10001 /srv/memos/data
sudo tar -C /srv/memos -xzf /srv/memos/backups/memos-$(date +%F).tar.gz data config compose.yml
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/v1/instance/profile; echo
```

You should see: the same JSON as at the end of step 7, containing `"admin":` and no
`"needsSetup":true`. Your account survived a data directory that was deleted and rebuilt, and
you can sign in with the same password.

If you do not: `"needsSetup":true` here means the archive did not contain the database, so the
container started on an empty directory and offered you setup again. Do not create a second
account on top of it; restore the archive again and check `tar -tzf` on the file lists
`data/memos_prod.db`. Entries, tags and accounts live in that one file, and attached photos are
ordinary files under `data/assets`.

## 9. Updating later

New versions are listed at https://github.com/usememos/memos/releases. Take a backup first, then
edit the `image:` line in /srv/memos/compose.yml to the new tag and its digest. The Docker Hub
tag drops the leading `v`, so release `v0.31.0` is image tag `0.31.0`.

```bash
cd /srv/memos
docker compose pull
docker compose up -d
docker compose logs --tail 30 memos
```

You should see: migration lines, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/healthz` and profile checks from step 7 before you call the update done, and sign in as well,
because a server that answers `Service ready.` can still be failing a migration that only shows
up when the app loads a page.

## 10. What will probably go wrong

You will open https://<DOMAIN> after step 7 starts the container, land on a sign-in page with a
username box, a password box and no way to make an account, and conclude something is broken. I
did, and I spent ten minutes re-reading the compose file. Nothing was wrong: closing registration
in step 2 also removes the sign-up link from the sign-in page, and the first-run form lives at
https://<DOMAIN>/auth/signup whether or not anything links to it. Go straight to that path. Do
not switch `disallowUserRegistration` back to false to make the link reappear; that reopens the
server to anyone who finds the hostname.

## 11. Out of scope

- Do not set `MEMOS_INSTANCE_URL`. Upstream uses it as the switch for anonymous public access,
  and this install is a private journal that answers strangers with a sign-in page.
- Do not switch `MEMOS_DRIVER` to postgres or mysql. SQLite is the choice here, and it is what
  makes this one container and one file to copy.
- Do not configure SMTP, an S3 bucket or an AI provider in the instance settings. Each is an
  account somewhere else, and the file written in step 2 owns the general settings group only.
- Do not install the Telegram integration or the web clipper. They are separate upstream
  services with their own containers, and this prompt installs the server they would talk to.

306 lines · 14,981 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Memos 0.30.0 under ~/selfhost/memos, answering at http://localhost:8131.

## 1. Preflight

Say all three of these to the user before step 2 runs; together they decide whether they want
this install at all. Memos is a capture stream: short markdown notes with tags, newest first,
read and written in a browser. On this path it answers only at http://localhost:8131, so the
phone they would reach for at the moment worth writing down cannot open it, and neither can a
laptop on the same wifi. And nothing here is end-to-end encrypted: every entry is a row in a
SQLite file on this disk, which is a different promise from a journal that syncs encrypted to a
company's servers, not a smaller one.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Memos needs 512 MB of RAM available and
5 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints free
memory, so one floor covers all three; on macOS and Windows that is the host's, and Docker
Desktop takes its share out of it. If available RAM is under 512 MB or free disk is under 5 GB,
print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

Three folders and one configuration file. That file is the security decision here, so it is
written before the container has ever run.

```bash
mkdir -p ~/selfhost/memos/data ~/selfhost/memos/config ~/selfhost/memos/backups
cat > ~/selfhost/memos/config/memos-instance-setting-general.json <<'EOF'
{
  "key": "GENERAL",
  "generalSetting": {
    "disallowUserRegistration": true
  }
}
EOF
chmod 644 ~/selfhost/memos/config/memos-instance-setting-general.json
ls -la ~/selfhost/memos ~/selfhost/memos/config
```

Assert: `ls -la` shows `data`, `config` and `backups`, and the JSON file at mode `644`. No
ownership fix runs here: the image entrypoint starts as root, hands its data directory to uid
10001 and re-execs as that user, which is why step 5 pins no `user:`. On Linux that leaves
`data` owned by 10001, which step 8 accounts for.

## 4. Secrets

No secret is generated for this install and there is no `.env` file. Memos keeps its session key
inside its database, and the only credential a human ever types is the administrator account
created in a browser at step 7. That is why this block has nothing to run.

Step 3 replaces the usual scramble. Most first-run installs leave registration open between the
container starting and a human claiming the account. Here `disallowUserRegistration` is on before
the first request arrives, and the first account still gets through, because an instance with
zero users takes the setup path rather than the registration path. Step 7 asserts both halves.
Adding a second person later means creating the account for them as administrator.

## 5. compose.yml

```bash
cat > ~/selfhost/memos/compose.yml <<'EOF'
# Memos · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ....... https://www.usememos.com/docs/deploy/docker-compose
#   configuration ........ https://www.usememos.com/docs/configuration/environment-variables
#   security ............. https://www.usememos.com/docs/configuration/security
#   provisioning ......... https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/memos/, so one file works on macOS, Linux and Windows and you can
# open data/ in Finder or Explorer. No named volume and no `user:` line: the
# image entrypoint starts as root, hands /var/opt/memos to uid 10001 and
# re-execs as that user, which on Linux leaves ./data owned by 10001, so a
# host-side backup may need sudo. MEMOS_INSTANCE_URL is absent because upstream
# treats an instance without one as private. The read-only /etc/secrets bind
# carries one deployment configuration file, written in step 3, that turns
# self-registration off before the first request is served. Digest read
# 2026-08-06; amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  memos:
    image: neosmemo/memos:0.30.0@sha256:71a5b4738d1bed96e92112004054f0888e92791b64eb78afd79077c96e6f9327
    container_name: memos
    restart: unless-stopped
    environment:
      MEMOS_PORT: "5230"
      MEMOS_DATA: /var/opt/memos
      MEMOS_DRIVER: sqlite
      # No MEMOS_INSTANCE_URL here. Empty means private, and private means an
      # anonymous visitor gets the sign-in page and nothing else.
    volumes:
      # memos_prod.db plus the assets/ folder that attachments land in.
      - ./data:/var/opt/memos
      # Deployment configuration, read once at start-up and never written to.
      - type: bind
        source: ./config
        target: /etc/secrets
        read_only: true
    ports:
      # Loopback only: no other device on the wifi can reach 8131.
      - "127.0.0.1:8131:5230"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5230/healthz"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
EOF
cd ~/selfhost/memos && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one folder you can open.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so the sign-in form works without one.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8131 is bound to 127.0.0.1, this computer only. For a journal that is the sharp edge of this
path: the phone in a pocket cannot add an entry. That is the trade, not a fault. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/memos/compose.yml
```

Assert: two lines, the container's own health check and the published port
`- "127.0.0.1:8131:5230"` on this machine.

## 7. Start and verify

```bash
cd ~/selfhost/memos
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8131/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8131/healthz; echo
curl -sS http://localhost:8131/api/v1/instance/profile; echo
curl -sS http://localhost:8131/api/v1/instance/settings/GENERAL; echo
```

Assert all four, printing what you received for each: the loop ends on `200`; the health endpoint
answers `Service ready.`; the profile JSON contains `"version":"0.30.0"` and `"needsSetup":true`;
the settings JSON contains `"disallowUserRegistration":true`. Those last two are the security
assert here: registration is shut before any account exists. If the loop never reaches 200, stop,
run `docker compose logs --tail 40 memos`, and name the likely cause: a container that exits at
once is usually step 3, because a malformed file under that mount makes Memos refuse to start
rather than ignore it. If `port is already allocated` came back, find what holds 8131 with
`lsof -nP -iTCP:8131 -sTCP:LISTEN`, or `netstat -ano | findstr :8131` on Windows, and stop until
it is free. A running container is not success.

STOP: tell the user to open http://localhost:8131/auth/signup, create their account, put the
password in their password manager, and wait. Do not continue until they confirm. That exact path
matters: the sign-in page at http://localhost:8131 carries no sign-up link, because step 3 turned
registration off. The screen at /auth/signup reads `Set up your instance` above
`Create the administrator account for this instance.`, with a `First run` badge and a
`Create admin account` button.

Once they confirm:

```bash
curl -sS http://localhost:8131/api/v1/instance/profile; echo
```

Assert: the response now contains `"admin":` and no longer contains `"needsSetup":true`. That is
the setup path closed behind them. If it still prints `"needsSetup":true`, the account was not
created; do not go on.

## 8. First backup and restore

One archive: the database, the photos, the deployment configuration and the compose file. Take
it before the user writes anything they would miss.

```bash
cd ~/selfhost/memos
docker compose stop
tar -C ~/selfhost/memos -czf ~/selfhost/memos/backups/memos-$(date +%F).tar.gz data config compose.yml
docker compose start
ls -lh ~/selfhost/memos/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container stops on purpose for
about five seconds, because a SQLite database copied mid-write is not a backup. On Linux the
container took ownership of `data` at first start, so if tar reports `Permission denied`, rerun
the line with `sudo` and then `sudo chown "$USER" ~/selfhost/memos/backups/*.tar.gz`.

That archive sits on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder a sync service
watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows drive is `/d/Backups`,
not `D:\Backups`. Assert: the user confirms the filename is listed there. If they have nowhere,
say plainly that this install has no backup.

To restore: `cd ~/selfhost/memos`, `docker compose down`, `rm -rf data` (with `sudo` on Linux,
for the reason above), untar the archive back in, then `docker compose up -d`. Entries, tags and
accounts live in `data/memos_prod.db`, and photos are ordinary files under `data/assets`, so one
photo can be pulled out of the archive without restoring anything. That is the disaster plan.

## 9. Updating later

New versions are listed at https://github.com/usememos/memos/releases. Back up first, then edit
the image line in ~/selfhost/memos/compose.yml to the new tag and digest. The Docker Hub tag
drops the leading `v`: release `v0.31.0` is image tag `0.31.0`.

```bash
cd ~/selfhost/memos
docker compose pull
docker compose up -d
docker compose logs --tail 30 memos
```

Memos migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's checks before calling this done.

## 10. What will probably go wrong

I wrote three entries on a Tuesday, shut the laptop, opened it on Wednesday, and got a browser
error page at http://localhost:8131 that read like the database was gone. Nothing was gone.
Docker Desktop had not started with the session, so nothing was listening on 8131, and
`restart: unless-stopped` does nothing until the Docker daemon is up. Turn on Docker Desktop's
start-at-login setting, and after any reboot run `cd ~/selfhost/memos && docker compose up -d`
before concluding a single entry is lost.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not set `MEMOS_INSTANCE_URL` and do not rebind 8131 to 0.0.0.0 so a phone on the same wifi
  can reach it. Together those put somebody's journal on every network this computer joins.
- Do not switch `MEMOS_DRIVER` to postgres or mysql. SQLite is the choice here, and it is what
  makes this one container and one folder to copy.
- Do not configure SMTP, an S3 bucket or an AI provider in the instance settings. Each is an
  account somewhere else, and the file written in step 3 owns the general settings group only.
compose.local.ymlthe services, pinned · local layout48 lines

authored from upstream docs, never pasted · 2,315 bytes

# Memos · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ....... https://www.usememos.com/docs/deploy/docker-compose
#   configuration ........ https://www.usememos.com/docs/configuration/environment-variables
#   security ............. https://www.usememos.com/docs/configuration/security
#   provisioning ......... https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/memos/, so one file works on macOS, Linux and Windows and you can
# open data/ in Finder or Explorer. No named volume and no `user:` line: the
# image entrypoint starts as root, hands /var/opt/memos to uid 10001 and
# re-execs as that user, which on Linux leaves ./data owned by 10001, so a
# host-side backup may need sudo. MEMOS_INSTANCE_URL is absent because upstream
# treats an instance without one as private. The read-only /etc/secrets bind
# carries one deployment configuration file, written in step 3, that turns
# self-registration off before the first request is served. Digest read
# 2026-08-06; amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  memos:
    image: neosmemo/memos:0.30.0@sha256:71a5b4738d1bed96e92112004054f0888e92791b64eb78afd79077c96e6f9327
    container_name: memos
    restart: unless-stopped
    environment:
      MEMOS_PORT: "5230"
      MEMOS_DATA: /var/opt/memos
      MEMOS_DRIVER: sqlite
      # No MEMOS_INSTANCE_URL here. Empty means private, and private means an
      # anonymous visitor gets the sign-in page and nothing else.
    volumes:
      # memos_prod.db plus the assets/ folder that attachments land in.
      - ./data:/var/opt/memos
      # Deployment configuration, read once at start-up and never written to.
      - type: bind
        source: ./config
        target: /etc/secrets
        read_only: true
    ports:
      # Loopback only: no other device on the wifi can reach 8131.
      - "127.0.0.1:8131:5230"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5230/healthz"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s

agent-readable mirror: /self-host/day-one.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned47 lines

authored from upstream docs, never pasted · 2,270 bytes

# Memos · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://www.usememos.com/docs/deploy/docker-compose
#   configuration ........ https://www.usememos.com/docs/configuration/environment-variables
#   security ............. https://www.usememos.com/docs/configuration/security
#   provisioning ......... https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# One service and one SQLite file. There is no `user:` line on purpose: the
# image entrypoint starts as root, hands /var/opt/memos to uid 10001 and
# re-execs as that user, so pinning a uid here would undo the fix it performs
# for you. MEMOS_INSTANCE_URL is deliberately absent, because upstream treats an
# instance without one as private and limits anonymous callers to the sign-in
# endpoints. The read-only /etc/secrets bind carries one deployment
# configuration file, written in step 2, that turns self-registration off before
# the first request is ever served. Tag and digest read from Docker Hub on
# 2026-08-06; the image publishes amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  memos:
    image: neosmemo/memos:0.30.0@sha256:71a5b4738d1bed96e92112004054f0888e92791b64eb78afd79077c96e6f9327
    container_name: memos
    restart: unless-stopped
    environment:
      MEMOS_PORT: "5230"
      MEMOS_DATA: /var/opt/memos
      MEMOS_DRIVER: sqlite
      # No MEMOS_INSTANCE_URL here. Empty means private, and private means an
      # anonymous visitor gets the sign-in page and nothing else.
    volumes:
      # memos_prod.db plus the assets/ folder that attachments land in.
      - /srv/memos/data:/var/opt/memos
      # Deployment configuration, read once at start-up and never written to.
      - type: bind
        source: /srv/memos/config
        target: /etc/secrets
        read_only: true
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8131.
      - "127.0.0.1:8131:5230"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5230/healthz"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
Caddyfilethe hostname and TLS35 lines

authored from upstream docs, never pasted · 1,425 bytes

# Memos · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.usememos.com/docs/deploy/reverse-proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Memos runs its own
# HTTP server and upstream still asks you to put a proxy in front of it that
# terminates TLS. This is that proxy.

<DOMAIN> {
	# The app bundle and the JSON API compress well. Caddy's default encode
	# matcher covers text, JSON, JavaScript and SVG only, so a photo attached
	# to an entry passes through untouched.
	encode zstd gzip

	# Memos sets no frame or transport headers of its own on the app routes,
	# so they are set here. HSTS is on because every request to this host
	# carries the session cookie for somebody's journal.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8131 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a 30 MB attachment upload gets through,
	# and it flushes text/event-stream as it arrives, which is what the live
	# timeline updates ride on.
	reverse_proxy 127.0.0.1:8131
}
install.shthe same install, no agent167 lines

authored from upstream docs, never pasted · 7,553 bytes

#!/usr/bin/env bash
# Memos · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=notes.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.usememos.com/docs/deploy/docker-compose
#   https://www.usememos.com/docs/configuration/environment-variables
#   https://www.usememos.com/docs/configuration/security
#   https://www.usememos.com/docs/getting-started
#   https://github.com/usememos/memos/blob/v0.30.0/docs/configuration-provisioning.md
#
# No secret is generated here and no .env file is written. Memos keeps its own
# session key inside its database, and the only credential a human types is the
# administrator password chosen in a browser after this script finishes.
#
# The one configuration file this script writes turns self-registration off
# before the container has ever started, so there is no window in which a
# stranger can sign themselves up. The very first account still gets through,
# because an instance with zero users takes the setup path rather than the
# registration path. That account is yours to claim: the summary at the end
# gives you the exact URL and the command that proves the window is shut.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/memos}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. notes.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# data belongs to uid 10001, the uid the container re-execs as. config is yours
# and world-readable, because the container user is not the login user and the
# file inside it holds a policy flag rather than a credential.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 -o "$(id -u)" -g "$(id -g)" "$APP_DIR/config"
sudo install -d -m 750 -o 10001 -g 10001 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. The one configuration file, written before the first boot ------------

cat > "$APP_DIR/config/memos-instance-setting-general.json" <<'GENERAL'
{
  "key": "GENERAL",
  "generalSetting": {
    "disallowUserRegistration": true
  }
}
GENERAL
chmod 644 "$APP_DIR/config/memos-instance-setting-general.json"

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-memos"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8131 is not one of them --------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8131 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/healthz"
for _ in $(seq 1 24); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthz" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/healthz answered ${code:-nothing}. Check: docker compose logs --tail 40 memos"

body="$(curl -sS "https://${DOMAIN_HOST}/healthz" || true)"
[ "$body" = "Service ready." ] || die "/healthz answered 200 with '${body}' instead of 'Service ready.'. Stop and investigate."

# The pinned release is the one actually answering, and nobody has claimed the
# administrator account yet. Both are expected here; the second is the thing you
# have to go and fix in a browser.
profile="$(curl -sS "https://${DOMAIN_HOST}/api/v1/instance/profile" || true)"
case "$profile" in
	*'"version":"0.30.0"'*) ;;
	*) die "the instance profile did not report version 0.30.0. Got: ${profile}" ;;
esac
case "$profile" in
	*'"needsSetup":true'*) ;;
	*) die "the instance profile did not report needsSetup true, so this server already has an account. Stop and investigate." ;;
esac

# The security assert: self-registration is off before anyone could have used it.
general="$(curl -sS "https://${DOMAIN_HOST}/api/v1/instance/settings/GENERAL" || true)"
case "$general" in
	*'"disallowUserRegistration":true'*) ;;
	*) die "registration is not disabled. The file in ${APP_DIR}/config was not read; check: docker compose logs --tail 40 memos" ;;
esac

# --- 7. The first backup, before day one ends --------------------------------
#
# The database, the photos, the deployment configuration and the live Caddy
# config. One archive, because there is only one thing here to lose.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/memos-${STAMP}.tar.gz" -C "$APP_DIR" data config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/memos-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Memos 0.30.0 is answering at https://${DOMAIN_HOST}/healthz

	  1. Do this first, now, before anything else. Open
	       https://${DOMAIN_HOST}/auth/signup
	     and create your account on the screen headed "Set up your instance".
	     That exact path matters: the sign-in page has no sign-up link, because
	     registration is already off. Until you claim it, the first person to
	     load that path becomes the administrator of this server. Then prove the
	     window is shut:
	       curl -sS https://${DOMAIN_HOST}/api/v1/instance/profile
	     That must no longer contain "needsSetup":true.
	  2. Use a password from your password manager and save it there before you
	     submit the form. It is the only credential this install has, and
	     nothing on this server has a copy of it in plain text.
	  3. Nobody can sign themselves up here. To add a second person, create the
	     account for them from the administrator settings.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/memos/

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Day One.

  • Nothing here is end-to-end encrypted. Day One holds your entries as ciphertext it cannot read; this server reads every word, and you are the server. That is a different arrangement rather than a smaller one: nobody else holds a key, and nobody else can lose one. The protection now lives in disk encryption on the box and in what you do with the backup archive, so treat both as if they were the journal, because they are.
  • The phone is the weak half, and it is the half where journaling actually happens. There is no first-party mobile app. Memos ships a web app manifest, so adding it to a home screen gives a full-screen icon, but there is no offline cache: with no signal there is no capture. Moe Memos, the third-party iOS and Android client, says it supports Memos up to 0.29.1, so as of today it does not speak this release.
  • Self-registration is off before the first request, not after. The install writes one deployment configuration file that upstream reads at start-up, so there is never a window where a stranger can sign themselves up. The cost is that the general settings group belongs to that file: the instance title, the week start day and the registration switch change by editing it and restarting the container, and the buttons for them in the admin screen will refuse.
  • You own the backups, and they are small. Entries, tags and accounts are one SQLite file, attached photos are ordinary files next to it, and the whole thing is one archive you can copy while the container is stopped. Nothing else on the box matters.
  • No on-this-day resurfacing, no daily prompts, no AI reflection, no audio transcription, and no hardcover book at the end of the year. Those are what the paid tiers sell, and none of them arrives with the container.

Where this came from

“The first user becomes the host admin and can configure registration, authentication, storage, and other global settings.”

  • The first account created during setup becomes the instance admin, so the image ships no default account and no password. source
  • The published image sets MEMOS_PORT to 5230 and declares /var/opt/memos as its data volume, and its entrypoint hands that directory to uid 10001 before re-execing as that user. source
  • Memos reads deployment configuration from files under /etc/secrets once at start-up, so a GENERAL instance-setting file with disallowUserRegistration set closes self-registration before the first request is served. source
  • An instance with no MEMOS_INSTANCE_URL configured is treated as private: anonymous callers are limited to the sign-in endpoints and the web UI redirects them to the sign-in page. source
  • Moe Memos, the third-party iOS and Android client, states that it supports Memos 0.21.0 and 0.27.0 to 0.29.1, which does not include the 0.30.0 release this install pins. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Day One?

    Not Day One itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Memos. A private capture stream for the thought you would otherwise lose: markdown entries, tags and a timeline, from one container. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 9 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Day One?

    Memos. A private capture stream for the thought you would otherwise lose: markdown entries, tags and a timeline, from one container. The right answer to the narrower question, said plainly. Memos does not replace Day One's apps, its encrypted sync or its printing, and no self-hosted project does; it replaces the daily capture loop and the timeline you scroll back through. One container, one SQLite file, markdown entries with tags and photos, and a private instance that answers a stranger with a sign-in page. What you give up is real: the server reads every entry, there is no first-party phone app, and the third-party iOS and Android client does not speak this release yet, so on a phone this is a web page saved to the home screen. What you get back is a journal nobody can price, rename or discontinue, on a disk you can hold. Memos is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Day One?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Memos itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Day One Silver, $8.99/mo — $107.88 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Memos install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Memos on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Memos on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer this answers only at http://localhost:8131, so the phone you would actually reach for at the moment worth writing down cannot open it, and the journal is only ever as current as the last time you sat at this desk. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.