# Can I self-host Grafana Cloud?

**YES** — it's called Grafana OSS. ONE EVENING setup · ~1.2 hours to running · 512 MB RAM minimum · $19/mo you stop paying ($228/yr on the Pro plan) — a metered rate, not a whole bill.

Grafana OSS authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/grafana-cloud/

## Install prompt (Claude Code)

````text
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 Grafana OSS 13.1.2 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. The hostname also becomes Grafana's `root_url`,
which the share links and the redirect after sign-in are built from, so a placeholder left in
place produces links that go nowhere.

Say one thing to the user before anything installs, because it decides whether they want this
at all: Grafana draws pictures of data it does not hold. It ships with no metrics and no logs
of its own, so this install ends at a working, empty dashboard tool. Something has to be
producing numbers already, or be installed separately after, before a panel has anything on it.

Upstream documents a minimum of 512 MB of memory and one CPU core. This install wants 512 MB
available and 5 GB free on /srv. The image publishes amd64 and arm64.

```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 certify
a hostname that does not resolve.

## 2. Layout

The image runs as uid 472 in group 0, so the data directory belongs to 472 and not to the login
user. Grafana does not chown that directory itself: it checks whether it can write there, prints
a warning, and carries on into a database it cannot create.

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

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by `472`. Nothing is
written outside /srv/grafana. `data` holds `grafana.db`, which is a SQLite file, so keep it on
this machine's own disk rather than any network mount.

## 3. Secrets

Two values are generated here, on the server. Do not print either, do not repeat them in your
summary, and do not put them in any log line. Hex rather than base64, because both travel
through an env file that Docker Compose also reads.

The first is the administrator password. Grafana creates its admin account on the very first
start, using whatever `GF_SECURITY_ADMIN_PASSWORD` says at that moment. Upstream's shipped value
for that setting is the literal word `admin`, so writing this file before the container has ever
run is what stops a known credential from existing.

The second is `GF_SECURITY_SECRET_KEY`. Grafana encrypts data-source passwords and alerting
credentials in its database with a key derived from it, and upstream ships a fixed string in
`conf/defaults.ini` that anyone can read. Set it now: upstream documents that changing it later
forces every stored data-source secret to be re-entered by hand.

```bash
umask 077
cat > /srv/grafana/.env <<EOF
GF_SERVER_ROOT_URL=https://<DOMAIN>
GF_SERVER_DOMAIN=<DOMAIN>
GF_SECURITY_ADMIN_PASSWORD=$(openssl rand -hex 24)
GF_SECURITY_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/grafana/.env
umask 022
ls -l /srv/grafana/.env
```

Assert: the file exists with mode `-rw-------` and `<DOMAIN>` on the first two lines has been
replaced by the real hostname. Tell the user the password is in /srv/grafana/.env, that they
read it themselves with `grep GF_SECURITY_ADMIN_PASSWORD /srv/grafana/.env`, and that it should
go into their password manager before step 7.

## 4. compose.yml

```bash
cat > /srv/grafana/compose.yml <<'EOF'
# Grafana OSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   docker config ...... https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   settings reference . https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#   health endpoint .... https://github.com/grafana/grafana/blob/v13.1.2/docs/sources/developer-resources/api-reference/http-api/api-legacy/other.md
#
# One container. Grafana keeps dashboards, users and data-source settings in an
# embedded SQLite database under /var/lib/grafana, so nothing else runs here.
# The image is grafana/grafana, not grafana/grafana-oss: upstream's docker page
# says the grafana-oss repository stopped being updated at the 12.4.0 release
# and that grafana/grafana is now the OSS image. Tag and digest were read from
# Docker Hub on 2026-08-06; the manifest list covers linux/amd64, linux/arm64
# and linux/arm/v7. The image runs as uid 472 in group 0, which is why step 2
# hands the data directory to that uid before the first start.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  grafana:
    image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
    container_name: grafana
    restart: unless-stopped
    # The hostname and the two generated values live here, mode 600.
    env_file: /srv/grafana/.env
    environment:
      # Caddy terminates TLS in front of this, so the session cookie can carry
      # the secure flag. Upstream ships it off because it cannot know.
      GF_SECURITY_COOKIE_SECURE: "true"
      # Already the upstream default. Written out because it is the setting
      # that decides whether a stranger who finds the hostname can enrol.
      GF_USERS_ALLOW_SIGN_UP: "false"
      # Upstream ships all three on: usage counters to stats.grafana.org every
      # 24 hours, plus version checks against grafana.com. Off here.
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
      GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
    volumes:
      # grafana.db, the plugin directory and rendered exports all land here.
      - /srv/grafana/data:/var/lib/grafana
    healthcheck:
      # curl is in the alpine image upstream builds. /api/health answers 200
      # while the database responds and 503 when it does not.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      retries: 10
      start_period: 30s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8106.
      - "127.0.0.1:8106:3000"
EOF
cd /srv/grafana && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Grafana serves on 3000 inside the container and 8106 is bound
to 127.0.0.1 on the host, so Caddy is the only route in.

## 5. Caddy and TLS

Append the block below 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-grafana
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Grafana OSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/ 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. That hostname is
# also GF_SERVER_ROOT_URL and GF_SERVER_DOMAIN in .env, because Grafana builds
# share links and redirect targets out of root_url rather than out of the Host
# header. The two have to say the same thing.

<DOMAIN> {
	encode zstd gzip

	# No X-Frame-Options here on purpose. Grafana's own allow_embedding
	# setting is false by default, so it already sends a deny, and setting
	# SAMEORIGIN from the proxy would replace a stricter header with a
	# looser one. HSTS is Caddy's because Grafana ships that off.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Grafana Live pushes dashboard and alert updates over a WebSocket at
	# /api/live/ws. Caddy negotiates the upgrade on its own, so there are no
	# Upgrade or Connection headers to set by hand.
	#
	# 8106 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:8106
}
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-grafana, reload, and report what it objected to. Caddy asks for the
certificate on the first request 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, 443/udp is
HTTP/3. 8106 stays closed: compose binds it to 127.0.0.1, so a rule would cover traffic that
cannot arrive, and one that is there was left by a previous run, which
`sudo ufw delete allow 8106` fixes. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8106.

## 7. Start and verify

Grafana builds its SQLite schema and creates the admin account on the first start, so give it a
moment before treating anything as broken.

```bash
cd /srv/grafana
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin https://<DOMAIN>/api/org
curl -sSL https://<DOMAIN>/login | grep -c '<title>Grafana</title>'
```

Assert, all four, and print what you received for each. The loop ends printing `200`. The health
response contains `"database": "ok"` and `"version": "13.1.2"`. The call using upstream's
shipped default credential prints `401`, which is the security assert in this block: it proves
the account Grafana would have created with a known password does not exist. The last command
prints `1`, the served login page.

If any of the four misses, stop, run `docker compose logs --tail 40 grafana`, and name the
likely earlier step. A log line reading `GF_PATHS_DATA='/var/lib/grafana' is not writable` is
step 2 done wrong, and the fix is `sudo chown -R 472:0 /srv/grafana/data`. A `502` from Caddy
with a healthy container is step 5. A `200` from the `-u admin:admin` call means the container
started before step 3 wrote the file, and the repair is `docker compose down`, then
`sudo rm -rf /srv/grafana/data`, then step 2 and step 7 again. A running container is not
success.

The first screen at https://<DOMAIN> is a sign-in form under the heading `Welcome to Grafana`.

STOP: tell the user to read their password with
`grep GF_SECURITY_ADMIN_PASSWORD /srv/grafana/.env`, put it in their password manager, then
open https://<DOMAIN>, sign in with the username `admin` and that password, and confirm they
reach a page offering to add a data source. Wait. Do not continue until they confirm in words.

## 8. First backup and restore

One archive: the SQLite database, the two generated values, the compose file and the live Caddy
site block, which together are the whole install. Stop first, because a SQLite file copied while
Grafana is writing to it is not a backup.

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

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds. A backup
on the same disk as the data is not a backup, so run this from the user's machine, not the
server:

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

To restore: `docker compose down`, `sudo rm -rf /srv/grafana/data`, then
`sudo tar -C /srv/grafana -xzf` the archive, then `sudo chown -R 472:0 /srv/grafana/data`, then
`docker compose up -d`. The archive also carries the Caddy site block as
`/srv/grafana/Caddyfile`; that one goes back into /etc/caddy by hand, and only if the host
config was lost too. Tell the user what is at stake: `.env` holds the key their data-source
passwords are encrypted with, so an archive without it is a store of secrets nobody can open
again.

## 9. Updating later

New versions are listed at https://github.com/grafana/grafana/releases. Take a backup first,
then edit the image line in /srv/grafana/compose.yml to the new tag and its digest:

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

Grafana migrates its own database on the way up, so watch that log until it settles, then re-run
the health check from step 7 before calling the update done. Read the release notes for any
major version step: upstream removes panel types between majors, and a dashboard that stops
rendering afterwards is usually that.

## 10. What will probably go wrong

Nothing will be wrong, and it will look wrong. I finished this install, signed in, and got a
sidebar, an empty dashboard list and a prompt to add a data source, and spent ten minutes
checking logs for a fault that was not there. Grafana holds no data of its own: it queries
Prometheus, a SQL database, a log store, something. Until one of those exists and is pointed at,
an empty screen is the correct output of a correct install. If the user expected charts on
arrival, tell them now, before they start debugging: the next thing to install is whatever is
going to produce the numbers.

## 11. Out of scope

- Do not install Prometheus, Loki, InfluxDB or any other data source. Each is its own service
  with its own storage and retention decisions, and this prompt installs the one that draws the
  pictures.
- Do not configure SMTP. Grafana runs without it; alert notifications can go to a webhook the
  user chooses later, and outbound mail from a fresh VPS is a separate fight.
- Do not enable anonymous access and do not configure an OAuth or LDAP provider. One
  administrator account with a generated password is the whole authentication model here.
- Do not switch the database to PostgreSQL or MySQL. SQLite is what makes this one container,
  and it is what step 8 is written for.
````

## Chat fallback

````text
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 Grafana OSS 13.1.2 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.

Read this before step 1, because it is what most people wish they had known. Grafana draws
pictures of data it does not hold. It ships with no metrics and no logs of its own, so the far
side of this install is a working, empty dashboard tool. Something has to be producing numbers
already, on this box or another one, before a panel has anything on it. Grafana Cloud bundles
those backends with the dashboard; this install is the dashboard.

## 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. Upstream documents 512 MB of memory and one CPU core as the
minimum.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and 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 you install, or the certificate is issued to somebody else's edge.

## 2. Layout

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

You should see: `backups` owned by you, and `data` owned by `472` in group `root`.

If you do not: leave `data` owned by 472 on purpose. The image runs as that uid and Grafana does
not chown its own data directory: it checks whether the path is writable, prints a warning, and
then fails to build its database. If you already ran this with the wrong owner, fix it with
`sudo chown -R 472:0 /srv/grafana/data`.

## 3. Secrets

Two values, both generated here on the server, both straight into a file only you can read. Hex
rather than base64 because both travel through an env file Docker Compose also reads.

The first is the administrator password. Grafana creates its admin account on the very first
start, using whatever `GF_SECURITY_ADMIN_PASSWORD` says at that moment, and upstream's shipped
value for that setting is the literal word `admin`. Writing this file before the container has
ever run is what stops a known credential from existing. The second is
`GF_SECURITY_SECRET_KEY`, which encrypts the data-source passwords and alerting credentials
Grafana stores in its database; upstream ships a fixed string for it in `conf/defaults.ini` that
anyone can read. Set it now, because upstream documents that changing it later forces every
stored data-source secret to be re-entered by hand.

```bash
umask 077
cat > /srv/grafana/.env <<EOF
GF_SERVER_ROOT_URL=https://<DOMAIN>
GF_SERVER_DOMAIN=<DOMAIN>
GF_SECURITY_ADMIN_PASSWORD=$(openssl rand -hex 24)
GF_SECURITY_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/grafana/.env
umask 022
ls -l /srv/grafana/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first two lines with your real hostname before you paste. Then read the password once with
`grep GF_SECURITY_ADMIN_PASSWORD /srv/grafana/.env` and put it in your password manager: it is
the only account this install has.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines in separate shells. Run `chmod 600 /srv/grafana/.env` and carry on. If the
file already existed from an earlier attempt, this block has overwritten both values, which is
harmless before the container has ever started and a problem afterwards: the admin password is
set once, at account creation, so a rewritten file does not change a password that already
exists, and a rewritten secret key makes every stored data-source password undecryptable.

Do not paste that file, either value, or any command output containing them into this chat
window. The agent path never sees them; a chat window hands them to a third party.

## 4. compose.yml

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

```bash
cat > /srv/grafana/compose.yml <<'EOF'
# Grafana OSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   docker config ...... https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   settings reference . https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#   health endpoint .... https://github.com/grafana/grafana/blob/v13.1.2/docs/sources/developer-resources/api-reference/http-api/api-legacy/other.md
#
# One container. Grafana keeps dashboards, users and data-source settings in an
# embedded SQLite database under /var/lib/grafana, so nothing else runs here.
# The image is grafana/grafana, not grafana/grafana-oss: upstream's docker page
# says the grafana-oss repository stopped being updated at the 12.4.0 release
# and that grafana/grafana is now the OSS image. Tag and digest were read from
# Docker Hub on 2026-08-06; the manifest list covers linux/amd64, linux/arm64
# and linux/arm/v7. The image runs as uid 472 in group 0, which is why step 2
# hands the data directory to that uid before the first start.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  grafana:
    image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
    container_name: grafana
    restart: unless-stopped
    # The hostname and the two generated values live here, mode 600.
    env_file: /srv/grafana/.env
    environment:
      # Caddy terminates TLS in front of this, so the session cookie can carry
      # the secure flag. Upstream ships it off because it cannot know.
      GF_SECURITY_COOKIE_SECURE: "true"
      # Already the upstream default. Written out because it is the setting
      # that decides whether a stranger who finds the hostname can enrol.
      GF_USERS_ALLOW_SIGN_UP: "false"
      # Upstream ships all three on: usage counters to stats.grafana.org every
      # 24 hours, plus version checks against grafana.com. Off here.
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
      GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
    volumes:
      # grafana.db, the plugin directory and rendered exports all land here.
      - /srv/grafana/data:/var/lib/grafana
    healthcheck:
      # curl is in the alpine image upstream builds. /api/health answers 200
      # while the database responds and 503 when it does not.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      retries: 10
      start_period: 30s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8106.
      - "127.0.0.1:8106:3000"
EOF
cd /srv/grafana && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/grafana/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal;
run `rm /srv/grafana/compose.yml` and paste again in one go. Grafana listens on 3000 inside the
container and 8106 is bound to 127.0.0.1 on the host, so Caddy is the only route in.

## 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-grafana
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Grafana OSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/ 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. That hostname is
# also GF_SERVER_ROOT_URL and GF_SERVER_DOMAIN in .env, because Grafana builds
# share links and redirect targets out of root_url rather than out of the Host
# header. The two have to say the same thing.

<DOMAIN> {
	encode zstd gzip

	# No X-Frame-Options here on purpose. Grafana's own allow_embedding
	# setting is false by default, so it already sends a deny, and setting
	# SAMEORIGIN from the proxy would replace a stricter header with a
	# looser one. HSTS is Caddy's because Grafana ships that off.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Grafana Live pushes dashboard and alert updates over a WebSocket at
	# /api/live/ws. Caddy negotiates the upgrade on its own, so there are no
	# Upgrade or Connection headers to set by hand.
	#
	# 8106 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:8106
}
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-grafana /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the
other. Caddy asks for the certificate on the first request and renews it with no cron job.

## 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 `8106`.

If you do not: delete anything for 8106 with `sudo ufw delete allow 8106`. That port is bound to
127.0.0.1 by the compose file, so a firewall rule 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 turned it off since, and `sudo ufw enable`
puts it back before you go any further.

## 7. Start and verify

Grafana builds its SQLite schema and creates the admin account on the first start, so give it a
moment before treating anything as broken.

```bash
cd /srv/grafana
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin https://<DOMAIN>/api/org
curl -sSL https://<DOMAIN>/login | grep -c '<title>Grafana</title>'
```

You should see, in order: the loop reaching `200`; a small JSON object containing
`"database": "ok"` and `"version": "13.1.2"`; then `401`; then `1`.

If you do not: the `401` is the one worth understanding. It means the API is up and refusing the
credential Grafana would have created for itself if step 3 had not run first, so seeing it is
good news, and a `200` there is the one result you must not ignore. If you get it, run
`docker compose down`, then `sudo rm -rf /srv/grafana/data`, then step 2 and this step again,
because a known password on a public hostname is the failure this whole sequence exists to
prevent. If the loop never reaches `200`, run `docker compose logs --tail 40 grafana`: a line
reading `GF_PATHS_DATA='/var/lib/grafana' is not writable` is step 2 done wrong, and a `502`
from Caddy against a container that looks healthy is step 5.

The first screen at https://<DOMAIN> is a sign-in form under the heading `Welcome to Grafana`.
Open it now, sign in with the username `admin` and the password from step 3, and confirm you
land on a page offering to add a data source. A running container is not success; that screen
is.

## 8. First backup and restore

One archive. It holds the SQLite database, the two generated values, the compose file and the
live Caddy site block, which together are the whole install. Stop first, because a SQLite file
copied while Grafana is writing to it is not a backup.

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

You should see: one file, a few hundred kilobytes on a fresh install. Downtime is a few seconds.

If you do not: an archive of about 100 bytes means `tar` matched nothing, which happens when you
run it from a different directory than the one the `-C` flags name. Run the command exactly as
written.

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/grafana
scp vps:/srv/grafana/backups/*.tar.gz ~/backups/grafana/
```

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

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 alias Prompt Zero created lives.

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

```bash
cd /srv/grafana
docker compose down
sudo rm -rf /srv/grafana/data
sudo tar -C /srv/grafana -xzf /srv/grafana/backups/grafana-$(date +%F).tar.gz data
sudo chown -R 472:0 /srv/grafana/data
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/health
```

You should see: the same JSON with `"database": "ok"`, and your password still signs you in.

If you do not: `no such file or directory` from `tar` means the archive name has a different
date; check `ls /srv/grafana/backups/`. An empty reply from `curl` usually means Grafana is still
opening the restored database, so wait another twenty seconds and run the last line again.
Understand the stakes before you skip this. `.env` holds
the key your data-source passwords are encrypted with, so an archive without it is a store of
secrets nobody can open again, including you. The archive also carries the Caddy site block as
`/srv/grafana/Caddyfile`; that one goes back into /etc/caddy by hand, and only if the host
config was lost too.

## 9. Updating later

New versions are listed at https://github.com/grafana/grafana/releases. Take a backup first,
then edit the `image:` line in /srv/grafana/compose.yml to the new tag and its digest.

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

You should see: migration lines, then the HTTP 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
health check from step 7 before you call the update done, and open one dashboard as well. Read
the release notes for any major version step: upstream removes panel types between majors, and a
dashboard that stops rendering afterwards is usually that rather than a broken install.

## 10. What will probably go wrong

Nothing will be wrong, and it will look wrong. I finished this install, signed in, and got a
sidebar, an empty dashboard list and a prompt to add a data source, and spent ten minutes
checking logs for a fault that was not there. Grafana holds no data of its own: it queries
Prometheus, a SQL database, a log store, something. Until one of those exists and is pointed at,
an empty screen is the correct output of a correct install. If you expected charts on arrival,
the next thing to install is whatever is going to produce the numbers.

## 11. Out of scope

- Do not install Prometheus, Loki, InfluxDB or any other data source. Each is its own service
  with its own storage and retention decisions, and this install gives you the one that draws
  the pictures.
- Do not configure SMTP. Grafana runs without it; alert notifications can go to a webhook you
  choose later, and outbound mail from a fresh VPS is a separate fight.
- Do not enable anonymous access and do not configure an OAuth or LDAP provider. One
  administrator account with a generated password is the whole authentication model here.
- Do not switch the database to PostgreSQL or MySQL. SQLite is what makes this one container,
  and it is what step 8 is written for.
````

## Local install prompt (your own computer, no server)

````text
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 Grafana OSS 13.1.2 under ~/selfhost/grafana, answering at http://localhost:8106.

## 1. Preflight

Say both of these to the user before step 2 runs; together they decide whether they want this
install at all. Grafana draws pictures of data it does not hold: it ships with no metrics and no
logs of its own, so this ends at an empty dashboard tool until something else makes numbers.
And it answers at http://localhost:8106, this computer and nowhere else: a dashboard link sent
to a colleague opens nothing, their phone cannot load it, and alert rules
evaluate only while this machine is awake. A laptop that closes at six is a monitor that stops
watching at six.

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. Upstream documents a minimum of 512 MB of
memory and one CPU core; this install wants 512 MB available and 5 GB free on the home disk, and
the image publishes amd64 and arm64. On macOS and Windows that figure is the host's, and Docker
Desktop takes its allocation out of it. If RAM is under 512 MB or disk 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

```bash
mkdir -p ~/selfhost/grafana/data ~/selfhost/grafana/backups
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" != "472" ]; then
  sudo chown -R 472:0 ~/selfhost/grafana/data
fi
ls -la ~/selfhost/grafana
```

Assert: `ls -la` lists `data` and `backups`, and nothing is written outside that folder. The
container runs as uid 472, so on Linux `data` has to belong to that uid; Grafana does not fix
that itself, it warns that its data path is not writable and then fails to build a database. On
macOS and Windows Docker Desktop grants access whatever the number on disk says, so the line
does nothing there. `data` holds `grafana.db`, a SQLite file: keep it on this computer's own
disk, not a sync folder or a network drive.

## 4. Secrets

Two values are generated here. Print neither, and keep both out of your summary and out of any
log line. Hex rather than base64, because both travel through an env file Compose reads.

Grafana creates its admin account on the very first start, using whatever
`GF_SECURITY_ADMIN_PASSWORD` says then, and upstream ships the literal word `admin` as that
setting's value: writing this file first is what stops a known credential from existing.
`GF_SECURITY_SECRET_KEY` encrypts data-source passwords and alerting credentials in the
database, and upstream ships a fixed string for it in `conf/defaults.ini` that anyone can read.
Set it now: upstream documents that changing it later forces every stored data-source secret to
be re-typed.

```bash
umask 077
cat > ~/selfhost/grafana/.env <<EOF
GF_SERVER_ROOT_URL=http://localhost:8106/
GF_SERVER_DOMAIN=localhost
GF_SECURITY_ADMIN_PASSWORD=$(openssl rand -hex 24)
GF_SECURITY_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/grafana/.env
umask 022
ls -l ~/selfhost/grafana/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. On Windows those mode bits are advisory: NTFS does not enforce them, and the
real boundary is the user's own account. Tell the user to read the password with
`grep GF_SECURITY_ADMIN_PASSWORD ~/selfhost/grafana/.env` and store it before step 7.

## 5. compose.yml

```bash
cat > ~/selfhost/grafana/compose.yml <<'EOF'
# Grafana OSS · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   docker config ...... https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   settings reference . https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#
# One container, every path relative to ~/selfhost/grafana/, so one file works
# on macOS, Linux and Windows. The data directory is a bind mount, not a named
# volume: Grafana never chowns it at runtime, so grafana.db stays visible in
# Finder. On Linux it must belong to uid 472, which step 3 arranges. The image
# is grafana/grafana, not grafana/grafana-oss: upstream stopped updating the
# grafana-oss repository at the 12.4.0 release. Digest read 2026-08-06, for
# linux/amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  grafana:
    image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
    container_name: grafana
    restart: unless-stopped
    # The address and the two generated values live here, mode 600.
    env_file: ./.env
    environment:
      # No TLS here, so the cookie cannot be marked secure: a browser would
      # refuse to send it back over plain http.
      GF_SECURITY_COOKIE_SECURE: "false"
      # Already upstream's default, written out because it decides whether
      # anyone reaching this page can enrol.
      GF_USERS_ALLOW_SIGN_UP: "false"
      # Upstream ships all three on: usage counters to stats.grafana.org and
      # version checks against grafana.com.
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
      GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
    volumes:
      # grafana.db, plugins and rendered exports land here.
      - ./data:/var/lib/grafana
    healthcheck:
      # curl is in upstream's alpine image; /api/health answers 200 while the
      # database responds.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      retries: 10
      start_period: 30s
    ports:
      # Loopback only: no other device on the wifi can reach 8106.
      - "127.0.0.1:8106:3000"
EOF
cd ~/selfhost/grafana && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount.

## 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 anyway, so the login form still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8106 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the wifi,
not anyone on the internet. Confirm it:

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

Assert: one line, `- "127.0.0.1:8106:3000"`.

## 7. Start and verify

Grafana builds its SQLite schema and creates the admin account on the first start, so give it a
moment before treating anything as broken.

```bash
cd ~/selfhost/grafana
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8106/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8106/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin http://localhost:8106/api/org
curl -sSL http://localhost:8106/login | grep -c '<title>Grafana</title>'
```

Assert all four, and print what you received for each: the loop ends on `200`; the health
response contains `"database": "ok"` and `"version": "13.1.2"`; the call using upstream's
shipped default credential prints `401`, proving the account Grafana would have made with a
known password does not exist; the last prints `1`, the served login page.

If any of the four misses, stop, run `docker compose logs --tail 40 grafana` and name the likely
earlier step. `GF_PATHS_DATA='/var/lib/grafana' is not writable` in that log is step 3 gone wrong
on Linux. A `200` from the `-u admin:admin` call means the container started before step 4 wrote
the file: `docker compose down`, delete `data`, redo step 3, redo step 7. If `port is already
allocated` came back, find what holds 8106 (`lsof -nP -iTCP:8106 -sTCP:LISTEN`, or
`netstat -ano | findstr :8106` on Windows) and stop until the user frees it. A running container
is not success.

The first screen at http://localhost:8106 is a sign-in form under the heading
`Welcome to Grafana`.

STOP: tell the user to open http://localhost:8106, sign in with the username `admin` and the
password from step 4, and confirm they reach a page offering to add a data source. Wait. Do not
continue until they confirm in words.

## 8. First backup and restore

One archive: the SQLite database, the two generated values and the compose file, the whole
install. Stop the container first: a SQLite file copied while Grafana is writing is not a
backup.

```bash
cd ~/selfhost/grafana
docker compose stop
ARC=~/selfhost/grafana/backups/grafana-$(date +%F).tar.gz
if [ "$(uname -s)" = "Linux" ]; then
  sudo tar -C ~/selfhost/grafana -czf "$ARC" compose.yml .env data
  sudo chown "$(id -u):$(id -g)" "$ARC"
else
  tar -C ~/selfhost/grafana -czf "$ARC" compose.yml .env data
fi
docker compose start
ls -lh ~/selfhost/grafana/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds. The
branch exists because on Linux `data` belongs to the container's uid; on macOS and Windows those
files already read as the user's own.

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

To restore: `docker compose down`, delete `data`, untar the archive back into ~/selfhost/grafana,
re-run step 3 for the ownership, then `docker compose up -d` and re-run step 7's health check.
Tell the user what is at stake: `.env` holds the key their data-source passwords are encrypted
with, so an archive without it cannot be opened.

## 9. Updating later

New versions are listed at https://github.com/grafana/grafana/releases. Take a backup first,
then edit the image line in ~/selfhost/grafana/compose.yml to the new tag and its digest:

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

Grafana migrates its own database on the way up, so watch that log until it settles, then re-run
step 7's health check, and read the release notes for any major version step.

## 10. What will probably go wrong

I rebooted, opened the bookmark, and got a connection refused that read like a lost install. It
was not: Docker Desktop had not started with the session, so nothing was listening on 8106.
`restart: unless-stopped` acts only once the Docker daemon is up. Turn on its start-at-login
setting, and after a reboot run `cd ~/selfhost/grafana && docker compose up -d` before deciding
anything is broken.

## 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 install Prometheus, Loki, InfluxDB or any other data source. Each is its own service
  with its own storage, and this prompt installs the one that draws the pictures.
- Do not configure SMTP, and do not switch the database to PostgreSQL or MySQL. SQLite is what
  makes this one container and what step 8 is written for.
````

## docker-compose.yml

```yaml
# Grafana OSS · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   docker config ...... https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   settings reference . https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#   health endpoint .... https://github.com/grafana/grafana/blob/v13.1.2/docs/sources/developer-resources/api-reference/http-api/api-legacy/other.md
#
# One container. Grafana keeps dashboards, users and data-source settings in an
# embedded SQLite database under /var/lib/grafana, so nothing else runs here.
# The image is grafana/grafana, not grafana/grafana-oss: upstream's docker page
# says the grafana-oss repository stopped being updated at the 12.4.0 release
# and that grafana/grafana is now the OSS image. Tag and digest were read from
# Docker Hub on 2026-08-06; the manifest list covers linux/amd64, linux/arm64
# and linux/arm/v7. The image runs as uid 472 in group 0, which is why step 2
# hands the data directory to that uid before the first start.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  grafana:
    image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
    container_name: grafana
    restart: unless-stopped
    # The hostname and the two generated values live here, mode 600.
    env_file: /srv/grafana/.env
    environment:
      # Caddy terminates TLS in front of this, so the session cookie can carry
      # the secure flag. Upstream ships it off because it cannot know.
      GF_SECURITY_COOKIE_SECURE: "true"
      # Already the upstream default. Written out because it is the setting
      # that decides whether a stranger who finds the hostname can enrol.
      GF_USERS_ALLOW_SIGN_UP: "false"
      # Upstream ships all three on: usage counters to stats.grafana.org every
      # 24 hours, plus version checks against grafana.com. Off here.
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
      GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
    volumes:
      # grafana.db, the plugin directory and rendered exports all land here.
      - /srv/grafana/data:/var/lib/grafana
    healthcheck:
      # curl is in the alpine image upstream builds. /api/health answers 200
      # while the database responds and 503 when it does not.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      retries: 10
      start_period: 30s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8106.
      - "127.0.0.1:8106:3000"
```

## compose.local.yml

```yaml
# Grafana OSS · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   docker config ...... https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   settings reference . https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#
# One container, every path relative to ~/selfhost/grafana/, so one file works
# on macOS, Linux and Windows. The data directory is a bind mount, not a named
# volume: Grafana never chowns it at runtime, so grafana.db stays visible in
# Finder. On Linux it must belong to uid 472, which step 3 arranges. The image
# is grafana/grafana, not grafana/grafana-oss: upstream stopped updating the
# grafana-oss repository at the 12.4.0 release. Digest read 2026-08-06, for
# linux/amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  grafana:
    image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
    container_name: grafana
    restart: unless-stopped
    # The address and the two generated values live here, mode 600.
    env_file: ./.env
    environment:
      # No TLS here, so the cookie cannot be marked secure: a browser would
      # refuse to send it back over plain http.
      GF_SECURITY_COOKIE_SECURE: "false"
      # Already upstream's default, written out because it decides whether
      # anyone reaching this page can enrol.
      GF_USERS_ALLOW_SIGN_UP: "false"
      # Upstream ships all three on: usage counters to stats.grafana.org and
      # version checks against grafana.com.
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
      GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
    volumes:
      # grafana.db, plugins and rendered exports land here.
      - ./data:/var/lib/grafana
    healthcheck:
      # curl is in upstream's alpine image; /api/health answers 200 while the
      # database responds.
      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/health || exit 1"]
      interval: 15s
      retries: 10
      start_period: 30s
    ports:
      # Loopback only: no other device on the wifi can reach 8106.
      - "127.0.0.1:8106:3000"
```

## Caddyfile

```text
# Grafana OSS · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/ 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. That hostname is
# also GF_SERVER_ROOT_URL and GF_SERVER_DOMAIN in .env, because Grafana builds
# share links and redirect targets out of root_url rather than out of the Host
# header. The two have to say the same thing.

<DOMAIN> {
	encode zstd gzip

	# No X-Frame-Options here on purpose. Grafana's own allow_embedding
	# setting is false by default, so it already sends a deny, and setting
	# SAMEORIGIN from the proxy would replace a stricter header with a
	# looser one. HSTS is Caddy's because Grafana ships that off.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Grafana Live pushes dashboard and alert updates over a WebSocket at
	# /api/live/ws. Caddy negotiates the upgrade on its own, so there are no
	# Upgrade or Connection headers to set by hand.
	#
	# 8106 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:8106
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Grafana OSS · 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=grafana.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/
#   https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/
#   https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/
#   https://github.com/grafana/grafana/blob/v13.1.2/docs/sources/developer-resources/api-reference/http-api/api-legacy/other.md
#
# Two values are generated here, on this machine, and neither is ever printed:
# the administrator password, because Grafana would otherwise create its admin
# account with the literal word admin as the password, and the encryption key
# that protects the data-source credentials Grafana stores in its database,
# because upstream ships a fixed one that anyone can read. Both land in
# /srv/grafana/.env with mode 600.
#
# DOMAIN_HOST becomes GF_SERVER_ROOT_URL, which Grafana builds share links and
# post-login redirects from, so it has to be the hostname you actually use.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/grafana}"
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. grafana.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."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; upstream documents 512 MB as the minimum"
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 ----------------------------------------------------
#
# The image runs as uid 472 in group 0 and never chowns its own data directory:
# it warns that the path is not writable and then fails to build a database.

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

# --- 3. Generate the two values, on the server -------------------------------
#
# Hex rather than base64 for both: this file is also read by Docker Compose, and
# base64 output can contain characters that make a shell interpolate. Read the
# password later with
#   grep GF_SECURITY_ADMIN_PASSWORD /srv/grafana/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		GF_SERVER_ROOT_URL=https://${DOMAIN_HOST}
		GF_SERVER_DOMAIN=${DOMAIN_HOST}
		GF_SECURITY_ADMIN_PASSWORD=$(openssl rand -hex 24)
		GF_SECURITY_SECRET_KEY=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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-grafana"
	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 8106 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; 8106 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}/api/health"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 grafana"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"database": "ok"' \
	|| die "/api/health answered 200 without a healthy database. Check: docker compose logs --tail 40 grafana"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"version": "13.1.2"' \
	|| die "/api/health reports a version other than 13.1.2. The pinned image is not what is running."

# The account Grafana would have created for itself must not exist. Upstream
# documents basic auth on the HTTP API, and a rejected credential returns 401.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' -u admin:admin "https://${DOMAIN_HOST}/api/org" || true)"
[ "$unauth" = "401" ] || die "the shipped default credential returned ${unauth}, not 401. Stop: take this host off DNS and investigate."

# The login page is served, not a proxy error page.
curl -sSL "https://${DOMAIN_HOST}/login" | grep -q '<title>Grafana</title>' \
	|| die "https://${DOMAIN_HOST}/login did not serve the Grafana login page. Check the Caddy site block."

# --- 7. The first backup, before day one ends --------------------------------
#
# The archive carries the live Caddy site block from /etc/caddy, not the
# <DOMAIN> template in $APP_DIR.

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

cat <<-DONE

	Grafana is answering at https://${DOMAIN_HOST}

	  1. Sign in with the username admin. The password is in $APP_DIR/.env,
	     mode 600, and it was not printed here. Read it with
	       grep GF_SECURITY_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager now.
	  2. What you will see is an empty dashboard tool. That is correct.
	     Grafana holds no data of its own, so nothing appears on a panel
	     until you add a data source and something is producing numbers for
	     it to query. Installing that data source is a separate job.
	  3. First backup written to $APP_DIR/backups: the database, the two
	     generated values, the compose file and the live Caddy site block.
	     It is on the same disk as the data, which is not a backup. Copy it
	     somewhere else tonight, and keep .env with it: it holds the key
	     your data-source passwords are encrypted with.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/grafana-cloud/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
