Can I self-host Miro?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Excalidraw. It takes one prompt, a 256 MB VPS, and about 6 minutes. That is $8 a month you stop paying Miro — $96 a year on the Starter plan, 1 seat assumed.

  • miro.com
  • Design & whiteboard
  • prices checked 2026-08-05

Why people pay for Miro

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.

Miro sells the thing a whiteboard is actually for: several people on the same board at the same time, from wherever they are, with the board still there next week. The paid tiers buy unlimited boards, the templates a facilitator reaches for during a live workshop, and an admin who can hand a board back after somebody leaves the company.

Miro plans and list prices
PlanList priceWhat it buys
FreefreeThree editable boards.
Starterthe plan this page prices against$8/mo per seatListed as $8/month per member, billed yearly. The monthly-billed rate was not shown on the page we read.
Business$20/mo per seatListed as $20/month per member, billed yearly. The monthly-billed rate was not shown on the page we read.
Enterprisequote onlyQuote only, and the page says it starts from 30 members.

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

Replaced by Excalidraw

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

A hand-drawn-style whiteboard that runs as one static container and keeps every drawing in the browser that drew it.

Matches the part of Miro most people use on their own: fast hand-drawn diagrams, arrows that stay attached, and a file you can export and keep. It matches none of the collaboration, because the image upstream publishes has no realtime server, so a team that bought Miro to run workshops together is not the reader this replaces anything for.

The swap

You're paying

Miro

$8/mo · $96/yr

is replaced by

You'd run

Excalidraw

ONE COMMAND · ~6 min to running · 256 MB RAM

Miro Starter · 1 seat assumed · vendor list price · checked 2026-08-05 · source · confidence: medium

Before you start

RAM floor
256 MBfloor from upstream docs — not measured by us yet
Disk
2 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
Time budget
~6 minunder 10 minutes, through the first backup

The prompt

One prompt, assuming Prompt Zero is done. It installs Excalidraw — read it before you paste it, which is the whole reason it is on the page instead of behind a download.

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

253 lines · 10,247 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 Excalidraw, pinned to the image digest in step 4, 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. The A record for it must already point at this server.

Excalidraw needs 256 MB of RAM available and 2 GB free on /srv. The image is published for
amd64 and arm64, so both work. Measure all four before touching anything:

```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 256 MB or free disk is under 2 GB, print both numbers and stop.
Do not install and hope. If `dig +short` prints nothing the A record does not exist yet,
so print that and stop: Caddy cannot get a certificate for a hostname that does not
resolve.

Read this next paragraph before you go further, because it changes what "working" means in
step 7. The image upstream publishes is the Excalidraw frontend on its own, an nginx
serving compiled JavaScript. There is no database, no account system and no server side
document store. A drawing is saved in the browser that drew it. This prompt installs
exactly that and nothing more.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/excalidraw /srv/excalidraw/backups
ls -la /srv/excalidraw
```

Assert: `ls -la` shows `backups` and shows the login user as owner. Everything for this
service lives under /srv/excalidraw and nothing is written outside it.

## 3. Secrets

There are none. Excalidraw has no accounts, no admin page and no database, so this install
generates no secret and writes no `.env`. Do not invent a token to make the install feel
more finished.

## 4. compose.yml

Write this file exactly as it appears. The pin is a digest rather than a version tag
because upstream publishes only a rolling tag for this image, so the digest is the version
here.

```bash
cat > /srv/excalidraw/compose.yml <<'EOF'
# Excalidraw · the deterministic fallback.
#
# Authored by caniselfhostit from the upstream documentation, not copied from a
# repository:
#   image and port ..... https://hub.docker.com/r/excalidraw/excalidraw
#   docker notes ....... https://docs.excalidraw.com/docs/introduction/development
#   collab server ...... https://github.com/excalidraw/excalidraw-room
#
# One container, and it is an nginx serving the compiled Excalidraw frontend.
# There is no database, no account system and no server side document store.
# Every drawing lives in the browser that drew it.
#
# Upstream publishes no versioned tag for this image, only a rolling one, so the
# pin is the multi-arch manifest digest read from Docker Hub on 2026-08-05, which
# covers linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  excalidraw:
    image: excalidraw/excalidraw@sha256:f7ee194addd607bf831d2af0f0a34463dd4225e426cf35199ef0b12a803398e9
    container_name: excalidraw
    restart: unless-stopped
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8083 never enters the firewall.
      - "127.0.0.1:8083:80"
EOF
cd /srv/excalidraw && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on port 80 inside itself, and 8083
on the host is bound to 127.0.0.1, so the only route in is through Caddy.

## 5. Caddy and TLS

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-excalidraw
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Excalidraw · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://hub.docker.com/r/excalidraw/excalidraw
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname whose A record already points at this box.
# Caddy asks for the certificate on the first request and renews it on its own,
# so there is nothing to schedule.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8083 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8083
}
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-excalidraw, reload, and report what it objected to. Caddy
requests the certificate on the first request to the hostname and renews it without a cron
job.

## 6. Firewall

Two ports open, both of them Caddy's, and 8083 is not one of them. These commands are
idempotent, so running them on a box Prompt Zero already configured changes nothing:

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

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3, which Caddy offers by default. 8083 stays closed: it is bound to
127.0.0.1, so a rule for it would be a rule for traffic that cannot arrive. If 8083
appears in that output a previous run left it there, and `sudo ufw delete allow 8083`
removes it.

Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and
shows no rule for 8083.

## 7. Start and verify

```bash
cd /srv/excalidraw
docker compose pull
docker compose up -d
sleep 10
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Excalidraw'
```

Assert: the first curl prints `200` and the second prints a number greater than `0`,
because `Excalidraw` appears in the title of the served document. Print what you actually
received for both. If either misses, stop, run `docker compose logs --tail 30 excalidraw`,
and say which earlier step is the likely cause. The usual one is DNS: a hostname whose A
record was created minutes ago makes Caddy's first certificate attempt fail and retry
quietly.

A running container is not success. Two asserts passing is success.

The first screen at https://<DOMAIN> is a blank white canvas with the drawing toolbar
across the top. There is no login form and no sign-up link, because there are no accounts.
Anyone who reaches this hostname gets their own blank canvas, and they cannot see the
user's drawings, because those never leave the user's browser.

## 8. First backup and restore

Two things need copying and only one of them is on this server. The configuration first:

```bash
cd /srv/excalidraw
tar -czf /srv/excalidraw/backups/excalidraw-config-$(date +%F).tar.gz compose.yml Caddyfile
ls -lh /srv/excalidraw/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped, because
there is no database to catch mid-write. A backup on the same disk as the data is not a
backup either, so run this one from the user's machine, not the server:

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

The drawings are the other thing, and no command on this server reaches them.

STOP: tell the user to open https://<DOMAIN>, draw one line, then use the app's export to
save the scene to a file on their own machine, and wait. Do not continue until they
confirm they have that file.

To restore the server, untar the archive into /srv/excalidraw, append the Caddy block
again, and run `docker compose up -d`. To restore a drawing, open the app and import the
exported file. Tell the user that is two disaster plans, that the second one holds their
work, and that exporting is a habit rather than a step they did once.

## 9. Updating later

There is no release page to watch for this image and no changelog tied to the tag, which
is a real cost of pinning it. Read the digest upstream publishes today, then edit
compose.yml:

```bash
docker pull excalidraw/excalidraw
docker image inspect --format '{{index .RepoDigests 0}}' excalidraw/excalidraw
```

Take a backup first. Put the digest that prints into the `image:` line in
/srv/excalidraw/compose.yml, then:

```bash
cd /srv/excalidraw
docker compose pull
docker compose up -d
docker compose logs --tail 20 excalidraw
```

## 10. What will probably go wrong

The drawings. I installed this, drew a diagram on my laptop, then opened the same hostname
on my phone and found an empty canvas, and I spent a few minutes certain the install was
broken. It was not. The container has nowhere to put a document, so the drawing was in the
laptop's browser storage and nowhere else. Clearing site data, using a private window or
switching devices loses work that was never on the server. If the user reports a drawing
has vanished, ask which browser they drew it in before you read any log.

## 11. Out of scope

- Do not install excalidraw-room or wire up live collaboration. That is a second service
  with its own socket transport, and this prompt installs one container.
- Do not add an S3 bucket, a database, or any storage backend. This image has no server
  side storage to point at one, so a bucket would sit there empty.
- Do not put basic auth in the Caddy block. If the user wants the board private the answer
  is a hostname they do not hand out, and that decision is theirs.
- Do not set analytics or telemetry environment variables. The published image ships
  without them, which is one of the reasons to run it.
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 Excalidraw, pinned to the image digest in step 4, 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.

## 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 `256` MB available, at least `2` G free, `amd64` or `arm64`, and
your server's IP address on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it at your
DNS provider, wait a minute, run `dig +short <DOMAIN>` again. Do not go on without it,
because Caddy cannot get a certificate for a hostname that does not resolve, and failed
attempts count against a rate limit.

One thing to know before you start, because it changes what "working" means in step 7. The
image upstream publishes is the Excalidraw frontend on its own, an nginx serving compiled
JavaScript. There is no database, no accounts and no server side document store. A drawing
is saved in the browser that drew it, and nowhere else.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/excalidraw /srv/excalidraw/backups
ls -la /srv/excalidraw
```

You should see: a `backups` directory, and your own username in the owner column.

If you do not: `install: cannot change owner` means your user cannot sudo, which is a
Prompt Zero problem rather than this one. If the directory already exists from an earlier
attempt, this command is safe to run again: it fixes the mode and the owner and leaves
anything inside it alone.

## 3. Secrets

There are none. Excalidraw has no accounts, no admin page and no database, so there is no
`.env` file and no token to generate. Nothing in this install needs to be kept secret.

Keep the habit anyway, because the next thing you self-host will have secrets: never paste
the contents of a `.env` file, a token, or any command output containing a password into a
chat window. The model does not need it, and once it is in the transcript it is somebody
else's copy.

## 4. compose.yml

The pin is a digest rather than a version tag because upstream publishes only a rolling
tag for this image, so the digest is the version. Paste this whole block at once,
including the last two lines.

```bash
cat > /srv/excalidraw/compose.yml <<'EOF'
# Excalidraw · the deterministic fallback.
#
# Authored by caniselfhostit from the upstream documentation, not copied from a
# repository:
#   image and port ..... https://hub.docker.com/r/excalidraw/excalidraw
#   docker notes ....... https://docs.excalidraw.com/docs/introduction/development
#   collab server ...... https://github.com/excalidraw/excalidraw-room
#
# One container, and it is an nginx serving the compiled Excalidraw frontend.
# There is no database, no account system and no server side document store.
# Every drawing lives in the browser that drew it.
#
# Upstream publishes no versioned tag for this image, only a rolling one, so the
# pin is the multi-arch manifest digest read from Docker Hub on 2026-08-05, which
# covers linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  excalidraw:
    image: excalidraw/excalidraw@sha256:f7ee194addd607bf831d2af0f0a34463dd4225e426cf35199ef0b12a803398e9
    container_name: excalidraw
    restart: unless-stopped
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8083 never enters the firewall.
      - "127.0.0.1:8083:80"
EOF
cd /srv/excalidraw && 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 heredoc lost its indentation
somewhere between the page and your terminal. Run `rm /srv/excalidraw/compose.yml` and
paste the whole block again in one go, and check that your terminal is not converting tabs
into something else. `docker: command not found` means you opened a shell on your own
machine instead of the server, so run `ssh vps` first.

## 5. Caddy and TLS

This appends one site block to the Caddy config that Prompt Zero installed. Replace
`<DOMAIN>` in the block below with your hostname before you paste it. 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-excalidraw
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Excalidraw · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://hub.docker.com/r/excalidraw/excalidraw
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname whose A record already points at this box.
# Caddy asks for the certificate on the first request and renews it on its own,
# so there is nothing to schedule.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8083 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8083
}
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: `adapting config` with a line number means the block landed inside another
site block. Run `sudo cp /etc/caddy/Caddyfile.before-excalidraw /etc/caddy/Caddyfile`,
then paste again, and check that the blank line from the second command is really there.
Caddy asks Let's Encrypt for the certificate on the first request to your hostname and
renews it on its own, so there is nothing to schedule and no renewal cron 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`, then rules for `80/tcp`, `443/tcp` and `443/udp`, and no
rule mentioning `8083`.

If you do not: a rule for `8083` from an earlier attempt should go, with
`sudo ufw delete allow 8083`. 8083 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would be a rule for traffic
that cannot arrive. 80/tcp is there to redirect to HTTPS and to answer the ACME challenge,
443/tcp is the only way in, and 443/udp is HTTP/3.

## 7. Start and verify

```bash
cd /srv/excalidraw
docker compose pull
docker compose up -d
sleep 10
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Excalidraw'
```

You should see: `200` from the first curl, and a number greater than `0` from the second,
because `Excalidraw` appears in the title of the page it served.

If you do not: `000` or `502` almost always means the certificate is not there yet. Run
`dig +short <DOMAIN>` once more, then `sudo journalctl -u caddy -n 30` to watch the ACME
attempt. If the certificate is fine but the second command prints `0`, run
`docker compose logs --tail 30 excalidraw` and look for the container restarting. A
container in `docker ps` is not proof of anything; the two commands above are.

Now open https://<DOMAIN> in a browser. The first screen is a blank white canvas with the
drawing toolbar across the top. There is no login form and no sign-up link, because there
are no accounts. Anyone who finds this hostname gets their own blank canvas, and they
cannot see your drawings, because your drawings never leave your browser.

## 8. First backup and restore

Two things need copying and only one of them is on the server. Start with the
configuration:

```bash
cd /srv/excalidraw
tar -czf /srv/excalidraw/backups/excalidraw-config-$(date +%F).tar.gz compose.yml Caddyfile
ls -lh /srv/excalidraw/backups/
```

You should see: one `.tar.gz` file with a size in the low single-digit kilobytes. Nothing
is stopped and nothing goes offline, because there is no database to catch mid-write.

If you do not: a size of `0` means the `cd` did not happen and tar found nothing to add.
Run the three lines again as one paste. `tar: compose.yml: Cannot stat` means step 4 wrote
the file somewhere else, so check `ls -la /srv/excalidraw` before you go on.

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

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

You should see: one file copied, and the same file listed by
`ls -lh ~/backups/excalidraw/`.

If you do not: `Permission denied (publickey)` means you ran it on the server by mistake.
The `vps:` prefix only means something on your own machine.

Now the drawings, which no command on the server can reach. Open https://<DOMAIN>, draw
one line, and use the app's export to save the scene as a file on your own machine.

You should see: a file on your own disk whose name ends in `.excalidraw`, a few kilobytes
in size. That file is the only backup of a drawing that exists anywhere.

If you do not: the export lives in the app's own menu, not in the browser's print dialog,
and the browser will have put the file wherever your downloads go rather than asking you.

To restore the server: untar the archive into /srv/excalidraw, paste the Caddy block from
step 5 again, and run `docker compose up -d`. To restore a drawing: open the app and
import the file you exported. Those are two separate disaster plans, the second one is the
one holding your work, and exporting is a habit rather than something you did once.

## 9. Updating later

There is no release page to watch for this image and no changelog tied to the tag, which
is a real cost of pinning it. Take a backup, then read the digest upstream publishes
today:

```bash
docker pull excalidraw/excalidraw
docker image inspect --format '{{index .RepoDigests 0}}' excalidraw/excalidraw
```

You should see: one line ending in `@sha256:` and 64 hex characters. Put that digest into
the `image:` line of /srv/excalidraw/compose.yml, then:

```bash
cd /srv/excalidraw
docker compose pull
docker compose up -d
docker compose logs --tail 20 excalidraw
```

You should see: `Recreated`, then nginx startup lines and no repeated restart.

If you do not: put the old digest back and run the same three commands. Nothing on this
server holds a drawing, so a bad update costs you a page, not your work. Write the digest
you replaced into a note next to the compose file, because there is no tag history to look
it up from later.

## 10. What will probably go wrong

The drawings. I installed this, drew a diagram on my laptop, then opened the same hostname
on my phone and found an empty canvas, and I spent a few minutes certain the install was
broken. It was not. The container has nowhere to put a document, so the drawing was in the
laptop's browser storage and nowhere else. Clearing site data, using a private window or
switching devices loses work that was never on the server. If a drawing vanishes, the
question is which browser you drew it in, not which log to read.

## 11. Out of scope

- Do not install excalidraw-room or wire up live collaboration. That is a second service
  with its own socket transport, and this install is one container.
- Do not add an S3 bucket, a database, or any storage backend. This image has no server
  side storage to point at one, so a bucket would sit there empty.
- Do not put basic auth in the Caddy block. If you want the board private, the answer is a
  hostname you do not hand out.
- Do not set analytics or telemetry environment variables. The published image ships
  without them, which is one of the reasons to run it.

agent-readable mirror: /self-host/miro.md

The files, if you'd rather do it yourself

The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.

compose.ymlthe services, pinned27 lines

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

# Excalidraw · the deterministic fallback.
#
# Authored by caniselfhostit from the upstream documentation, not copied from a
# repository:
#   image and port ..... https://hub.docker.com/r/excalidraw/excalidraw
#   docker notes ....... https://docs.excalidraw.com/docs/introduction/development
#   collab server ...... https://github.com/excalidraw/excalidraw-room
#
# One container, and it is an nginx serving the compiled Excalidraw frontend.
# There is no database, no account system and no server side document store.
# Every drawing lives in the browser that drew it.
#
# Upstream publishes no versioned tag for this image, only a rolling one, so the
# pin is the multi-arch manifest digest read from Docker Hub on 2026-08-05, which
# covers linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  excalidraw:
    image: excalidraw/excalidraw@sha256:f7ee194addd607bf831d2af0f0a34463dd4225e426cf35199ef0b12a803398e9
    container_name: excalidraw
    restart: unless-stopped
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8083 never enters the firewall.
      - "127.0.0.1:8083:80"
Caddyfilethe hostname and TLS25 lines

authored from upstream docs, never pasted · 845 bytes

# Excalidraw · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://hub.docker.com/r/excalidraw/excalidraw
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname whose A record already points at this box.
# Caddy asks for the certificate on the first request and renews it on its own,
# so there is nothing to schedule.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8083 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8083
}
install.shthe same install, no agent111 lines

authored from upstream docs, never pasted · 4,858 bytes

#!/usr/bin/env bash
# Excalidraw · 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=draw.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://hub.docker.com/r/excalidraw/excalidraw
#   https://docs.excalidraw.com/docs/introduction/development
#   https://caddyserver.com/docs/automatic-https
#
# There is nothing to generate here. Excalidraw has no accounts, no database and
# no server side documents, so this install creates no secrets at all.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/excalidraw}"
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. draw.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 256 ] || die "only ${avail_mb} MB of RAM available; this install wants 256 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 2 ] || die "only ${avail_gb} GB free on /srv; this install wants 2 GB"

# The A record has to exist before Caddy asks for a certificate, or the request
# fails and you learn that by burning a Let's Encrypt rate-limit slot.
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 ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
cd "$APP_DIR"
docker compose config >/dev/null

# --- 3. 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-excalidraw"
	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

# --- 4. Ports: two open, and 8083 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; 8083 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 5. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

# --- 6. Prove it works before claiming it does -------------------------------

echo "==> waiting for https://${DOMAIN_HOST}/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: docker compose logs excalidraw"

curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Excalidraw' \
	|| die "the page answered 200 but does not mention Excalidraw. Check: docker compose logs excalidraw"

# --- 7. The first backup, before day one ends --------------------------------
#
# What is on this server is the configuration, not the drawings. The drawings
# are in the browser that made them, which is why step 8 of prompt.md tells you
# to export one and copy it off the box as well.

tar -czf "$APP_DIR/backups/excalidraw-config-$(date +%Y%m%d-%H%M%S).tar.gz" \
	-C "$APP_DIR" compose.yml Caddyfile
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	Excalidraw is running at https://${DOMAIN_HOST}/

	  1. Open it. You get a blank canvas and a toolbar. There is no login,
	     because there are no accounts.
	  2. Anyone who reaches that hostname gets their own blank canvas. They
	     cannot see yours: your drawing is in your browser, not on this server.
	  3. Draw something, then use the app's export to save a .excalidraw file
	     and copy it off this machine. That file is your only real backup.
	  4. Config backup written to $APP_DIR/backups. It is on the same disk as
	     the thing it backs up, which is not a backup. Copy it somewhere else.

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 Miro.

  • No realtime collaboration. Upstream says so themselves: the self-hosted image has no sharing and no collab. Two people on one board needs a second service, excalidraw-room, and this install does not run it.
  • Your drawings are not on the server. They live in the browser that drew them, so there is no sync between your laptop and your phone, and clearing site data loses work no backup on the box could have saved. Exporting a scene file is the backup, and it is a habit rather than a step.
  • There are no accounts, which cuts both ways. Nobody can log in, so there is no password to lose and no sign-up page to close, but anyone who finds the hostname can open a blank canvas of their own. They cannot see yours.
  • Upstream publishes no versioned tag for this image, only a rolling one, so the install pins a digest and you read the new digest yourself when you update. There is no changelog attached to it.
  • No Miro templates, no facilitation tools, no shared board history. Those are what the paid tiers are actually selling, and none of them arrive with this container.

Where this came from

“At the moment, self-hosting your own instance doesn't support sharing or collaboration features.”

  • Upstream states plainly that a self-hosted instance does not support sharing or collaboration, and points at a separate collab server for that. source
  • The published image is an nginx serving the compiled frontend on container port 80, and it carries no analytics or tracking libraries. source
  • Live collaboration is a second service, excalidraw-room, which this install deliberately does not run. source
  • Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source

Questions people actually ask

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

  • Can I self-host Miro?

    Not Miro 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 Excalidraw. A hand-drawn-style whiteboard that runs as one static container and keeps every drawing in the browser that drew it. 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 6 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 Miro?

    Excalidraw. A hand-drawn-style whiteboard that runs as one static container and keeps every drawing in the browser that drew it. Matches the part of Miro most people use on their own: fast hand-drawn diagrams, arrows that stay attached, and a file you can export and keep. It matches none of the collaboration, because the image upstream publishes has no realtime server, so a team that bought Miro to run workshops together is not the reader this replaces anything for. Excalidraw is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Miro?

    256 MB of RAM and 2 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Excalidraw itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Miro Starter, $8/mo — $96 a year, 1 seat assumed.

  • 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 Excalidraw install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

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