Back to Article List

How to run Grafana with Docker Compose

How to run Grafana with Docker Compose - How to run Grafana with Docker Compose

The image is grafana/grafana for the open source build and grafana/grafana-enterprise for the Enterprise one. Both take configuration from GF_<SECTION>_<KEY> environment variables, which map one for one onto the INI keys in the Grafana configuration reference. Nothing else about the image is unusual.

Containers make sense for Grafana when it shares a host with the things it monitors, since the whole stack then lives in one file you can copy to another server. What follows is the compose file to start from and the traps that come with it.

A compose file that works

services:
  grafana:
    image: grafana/grafana:13.2.0
    container_name: grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=change-me-before-you-start-it
      - GF_SERVER_ROOT_URL=https://grafana.example.com/
    volumes:
      - grafana-storage:/var/lib/grafana

volumes:
  grafana-storage:

Bring it up with docker compose up -d and Grafana is on port 3000 a few seconds later.

restart: unless-stopped brings Grafana back after a host reboot without fighting you when you deliberately stop it. The named volume on /var/lib/grafana is the entire persistent state, including the SQLite database, so losing it loses every dashboard you have.

Pinning the tag to 13.2.0 and not latest keeps a docker compose pull six months from now from handing you a major version you didn't plan for. GF_SECURITY_ADMIN_PASSWORD replaces the default admin password, though only on the very first start, before the admin row exists in the database.

Setting GF_SECURITY_ADMIN_PASSWORD after the container has already run once changes nothing at all. The writeup on the Grafana default login has the reset command for when that leaves you locked out, and it's a one-liner you run inside the container. Get the password right on the first up and none of this comes up.

Named volume or bind mount

Use a named volume. Docker owns the permissions and the container starts on any host with no preparation. Backup is a docker run --rm -v grafana-storage:/data tar job. What you give up is being able to ls the files without root.

Bind mounts earn their place when you want the SQLite file inside a directory your existing backup agent already walks, or when you're mounting config in from a git checkout. For provisioning files, read-only bind mounts are exactly right. For /var/lib/grafana, they're where the permission errors come from.

The uid 472 permission error

The Grafana image runs as uid 472, gid 0. Bind-mount a directory you created as your own user and the container starts, fails to write and exits, with the entrypoint printing this first:

GF_PATHS_DATA='/var/lib/grafana' is not writable.
You may have issues with file permissions

The log line that follows is usually failed to connect to database: ... unable to open database file. Two fixes.

Hand the directory to uid 472 and leave the container running as its default user:

mkdir -p ./grafana-data
sudo chown -R 472:0 ./grafana-data

Or run the container as your own user, which is what the official docs suggest for a bind mount:

services:
  grafana:
    image: grafana/grafana:13.2.0
    user: "1000"
    volumes:
      - ./grafana-data:/var/lib/grafana

Match that 1000 to the output of id -u for the account that owns the directory. Both approaches work, and the official Docker installation docs describe each of them alongside plugin preinstallation via GF_PLUGINS_PREINSTALL. The chown version survives being copied to a host where your uid is different, so it's the one I use in anything I expect to move.

Why the image settled on 472 and not some other number, I have never found written down anywhere. It appears in the Dockerfile and in every permissions thread about Grafana as a bare fact with no story attached. Not that it matters much. You need the number, not the reason for it.

Keep the admin password out of the compose file

Any GF_* variable can be supplied as GF_*__FILE pointing at a file, and the entrypoint reads the file into the variable before Grafana starts. Note the exact spelling of the suffix: __FILE, not _FILE. Setting both the plain form and the __FILE form is an error and the container exits with a message saying so.

services:
  grafana:
    image: grafana/grafana:13.2.0
    environment:
      - GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin_password
    secrets:
      - grafana_admin_password
    volumes:
      - grafana-storage:/var/lib/grafana

secrets:
  grafana_admin_password:
    file: ./secrets/grafana_admin_password

volumes:
  grafana-storage:

Grafana has a second mechanism for the same problem, usable anywhere a config value is read: the $__file{} provider in grafana.ini.

[database]
password = $__file{/etc/secrets/gf_sql_password}

It trims whitespace from both ends of the file, which saves you from the trailing-newline bug that catches everyone who writes a secret with echo. Use GF_*__FILE for the Docker-native path and $__file{} when the value belongs in a mounted config file.

Provision data sources and dashboards from files

Clicking a Prometheus data source into existence is fine once. Doing it again after you rebuild the container is not, so put it in a file. The provisioning path inside the container is /etc/grafana/provisioning, and the two subdirectories you need here are datasources and dashboards.

volumes:
  - grafana-storage:/var/lib/grafana
  - ./provisioning:/etc/grafana/provisioning:ro
  - ./dashboards:/var/lib/grafana/dashboards:ro

Data source provisioning

./provisioning/datasources/prometheus.yml:

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    orgId: 1
    uid: prometheus
    url: http://prometheus:9090
    isDefault: true
    editable: false

That apiVersion: 1 is the provisioning file format version and it has nothing to do with your Grafana version. It is 1 and has been for years. A missing or wrong value makes Grafana skip the file, with a parse error in the log and no louder sign than that.

access: proxy means Grafana's backend makes the query, so the URL is resolved inside the Docker network. That's why http://prometheus:9090 works here and http://localhost:9090 does not. Setting editable: false blocks edits in the UI, so nobody loses an afternoon wondering why their change disappeared on the next restart.

Dashboard provisioning

Dashboards need a provider file that points at a directory of dashboard JSON. ./provisioning/dashboards/default.yml:

apiVersion: 1

providers:
  - name: 'default'
    orgId: 1
    folder: ''
    type: file
    disableDeletion: false
    updateIntervalSeconds: 10
    allowUiUpdates: false
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Drop exported dashboard JSON into ./dashboards and Grafana loads it within updateIntervalSeconds. With foldersFromFilesStructure: true, subdirectory names become Grafana folder names, which is the tidiest way to organise a growing set. Set allowUiUpdates: true if you want people saving changes from the UI, at the cost of those changes being clobbered the next time the file changes.

Why /var/log/grafana/grafana.log is empty in a container

Exec into a Grafana container, look for the log file everyone references, and it's there but empty. That's deliberate.

The image entrypoint ends with a command that includes cfg:default.log.mode="console". That forces console-only logging, so everything goes to stdout and nothing is written to the file. Reading logs in a container is therefore:

docker compose logs -f grafana

If you want a real file as well, say because a log shipper is watching a directory, override the mode. Because that cfg:default.* argument lands in Grafana's defaults layer and environment variables sit above it in the precedence order, a plain env var is enough:

environment:
  - GF_LOG_MODE=console file
  - GF_PATHS_LOGS=/var/log/grafana

volumes:
  - grafana-logs:/var/log/grafana

Now you get both stdout and /var/log/grafana/grafana.log. The guide to Grafana logs and where to find them goes into log levels and per-logger filters, which is the next thing you'll want once the file exists. Setting [log] mode = console file in a mounted grafana.ini does the same job for the same reason.

Grafana and Prometheus in one compose stack

The most common reason to use compose at all. Prometheus scrapes, Grafana draws and the two find each other by service name on the default compose network.

services:
  prometheus:
    image: prom/prometheus:v3.14.0
    container_name: prometheus
    restart: unless-stopped
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=30d'

  grafana:
    image: grafana/grafana:13.2.0
    container_name: grafana
    restart: unless-stopped
    depends_on:
      - prometheus
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin_password
    volumes:
      - grafana-storage:/var/lib/grafana
      - ./provisioning:/etc/grafana/provisioning:ro

volumes:
  prometheus-data:
  grafana-storage:

Note that Prometheus has no ports entry. It doesn't need one, because Grafana reaches it over the internal network on 9090 and nothing outside the stack has any business talking to it. Grafana's own port is published on 127.0.0.1 only, and the piece on the Grafana default port covers what changes if you map it wide open for testing. The ./provisioning mount carries over from the section above, and the provisioning documentation lists every key for both file types. Nothing else in that file needs explaining.

Building the dashboards on top of that is a separate exercise. The guide to building and managing Grafana dashboards covers the panel side once the containers are up, and the Grafana and Prometheus dashboard guide picks up the data path from a running stack. If you want a worked example against a real application, monitoring a Node.js app with Prometheus and Grafana uses this same shape with an exporter added.

Update the Grafana image

docker compose pull grafana
docker compose up -d grafana

Because the state lives in the volume, the new container picks up the same database and Grafana runs its schema migrations on start. Watch the log during that first start, since a failed migration is the one upgrade problem that leaves you with a container that restarts forever.

Back the volume up before a major version bump:

docker run --rm -v grafana-storage:/data -v "$PWD:/backup" \
  alpine tar czf /backup/grafana-$(date +%F).tar.gz -C /data .

Downgrades are not supported once migrations have run, so that tarball is the only route back. If any of this is going onto a host you also manage by hand, the package install on Ubuntu lays out the same setup without containers, and running compose stacks through Portainer stacks gives you this YAML with a UI over the top. Read the upgrade notes for the release you're jumping to before a major bump. The schema migrations that run on that first start are the part with no undo.

Your idea deserves better hosting

24/7 support 30-day money-back guarantee Cancel anytime
Billing Cycle

VPS.S1

$5.99 Save  17 %
$4.99 Monthly
  • 2 vCPU AMD EPYC
  • 2 GB RAMMEMORY
  • 30 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included

VPS.S3

$14.99 Save  33 %
$9.99 Monthly
  • 4 vCPU AMD EPYC
  • 6 GB RAMMEMORY
  • 70 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included

EPYC VPS.P1

$8.99 Save  22 %
$6.99 Monthly
  • 2 vCPU AMD EPYC
  • 4 GB RAMMEMORY
  • 40 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P2

$16.99 Save  24 %
$12.99 Monthly
  • 2 vCPU AMD EPYC
  • 8 GB RAMMEMORY
  • 80 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P4

$29.99 Save  23 %
$22.99 Monthly
  • 4 vCPU AMD EPYC
  • 16 GB RAMMEMORY
  • 160 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P5

$39.99 Save  25 %
$29.99 Monthly
  • 8 vCPU AMD EPYC
  • 16 GB RAMMEMORY
  • 180 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P6

$59.99 Save  25 %
$44.99 Monthly
  • 8 vCPU AMD EPYC
  • 32 GB RAMMEMORY
  • 200 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P7

$69.99 Save  29 %
$49.99 Monthly
  • 16 vCPU AMD EPYC
  • 32 GB RAMMEMORY
  • 240 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G2

$24.99 Save  20 %
$19.99 Monthly
  • 2 vCPUAMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture. AMD EPYC G4
  • 4 GB DDR5MEMORY
  • 50 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G4

$44.99 Save  22 %
$34.99 Monthly
  • 4 vCPUAMD EPYC processor with dedicated vCPU cores, on enterprise server hardware. AMD EPYC G4
  • 8 GB DDR5MEMORY
  • 100 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G6

$89.99 Save  22 %
$69.99 Monthly
  • 8 vCPUAMD EPYC processor with dedicated vCPU cores, on enterprise server hardware. AMD EPYC G4
  • 16 GB DDR5MEMORY
  • 200 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G7

$159.99 Save  22 %
$124.99 Monthly
  • 8 vCPUAMD EPYC processor with dedicated vCPU cores, on enterprise server hardware. AMD EPYC G4
  • 32 GB DDR5MEMORY
  • 250 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

AMD Ryzen VPS.R1

$16.99 Save  18 %
$13.99 Monthly
  • 1 dedicated CPU AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 4 GB DDR5MEMORY
  • 50 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

AMD Ryzen VPS.R2

$29.99 Save  17 %
$24.99 Monthly
  • 2 dedicated CPUs AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 8 GB DDR5MEMORY
  • 100 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

AMD Ryzen VPS.R4

$109.99 Save  18 %
$89.99 Monthly
  • 8 dedicated CPUs AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 32 GB DDR5MEMORY
  • 400 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

Questions?

Do I need docker-compose or docker compose?

The V2 plugin, invoked as docker compose with a space, is what ships with current Docker Engine installs and it's what these files assume. The standalone docker-compose V1 binary reached end of life and is no longer updated. You can also drop the version: key from the top of your compose file entirely; V2 ignores it and warns if you leave it in.