Skip to content

Instantly share code, notes, and snippets.

@grenade
Last active August 28, 2025 07:23
Show Gist options
  • Save grenade/b72490f341eae2cfafa3b81f6ebf6d7b to your computer and use it in GitHub Desktop.
Save grenade/b72490f341eae2cfafa3b81f6ebf6d7b to your computer and use it in GitHub Desktop.
GRAFANA_URL=https://grafana.example.com
GRAFANA_TOKEN=glsa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_a9999999
REPO_DIR=/srv/backups/grafana-dashboards
[email protected]:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.git
BRANCH=main
#!/usr/bin/env bash
set -euo pipefail
# --- Required env vars ---
# GRAFANA_URL="https://grafana.example.com"
# GRAFANA_TOKEN="grafana_api_token_with_reader_rights"
# REPO_DIR="/srv/backups/grafana-dashboards" # the checked-out git repo (origin already set)
# Optional: BRANCH="main"
: "${GRAFANA_URL:?GRAFANA_URL is required}"
: "${GRAFANA_TOKEN:?GRAFANA_TOKEN is required}"
: "${REPO_DIR:?REPO_DIR is required}"
BRANCH="${BRANCH:-main}"
cd "$REPO_DIR"
# Ensure git is set up
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "REPO_DIR is not a git repo"; exit 1; }
git checkout "$BRANCH"
mkdir -p dashboards
mkdir -p meta
auth_hdr=( -H "Authorization: Bearer ${GRAFANA_TOKEN}" )
common_curl=( -sS -f "${auth_hdr[@]}" )
slugify() {
# lowercase, spaces -> dashes, strip non alnum/- chars, trim dashes
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/-/g; s/[^a-z0-9-]//g; s/^-+|-+$//g'
}
echo "Fetching folders..."
folders_json="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")"
echo "$folders_json" | jq -S '.' > meta/folders.json
echo "Fetching dashboards list..."
# type=dash-db returns the “classic” dashboards in the UI
search_json="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/search?type=dash-db&limit=5000")"
echo "$search_json" | jq -S '.' > meta/search.json
# Build folderId -> folderTitle map (with General folder as id 0)
declare -A FID2TITLE
FID2TITLE[0]="General"
while IFS=$'\t' read -r id title; do
FID2TITLE["$id"]="$title"
done < <(echo "$folders_json" | jq -r '.[] | [.id, .title] | @tsv')
echo "Exporting dashboards..."
# For each dashboard, fetch the full definition by UID and save a normalized JSON
echo "$search_json" | jq -r '.[] | select(.type=="dash-db") | [.uid, .title, (.folderId // 0)] | @tsv' | \
while IFS=$'\t' read -r uid title folder_id; do
folder="${FID2TITLE[$folder_id]:-General}"
safe_folder="$(slugify "$folder")"
safe_title="$(slugify "$title")"
out_dir="dashboards/${safe_folder}"
mkdir -p "$out_dir"
full="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/dashboards/uid/${uid}")"
# Extract the “dashboard” object and normalize for clean diffs:
# - force id=null (server-managed)
# - remove fields that churn (iteration, version) – optional, comment out if you prefer to keep version history
normalized="$(echo "$full" | jq -S '
.dashboard
| .id = null
| del(.iteration)
| del(.version)
')"
# Save with UID in filename to avoid collisions on renamed dashboards
out_file="${out_dir}/${safe_title}-${uid}.json"
echo "$normalized" > "$out_file"
# also save a tiny meta file with original folder & title (handy during restores)
printf '%s\n' "$full" | jq -S '{uid: .meta.uid, title: .meta.slug, folderTitle: .meta.folderTitle, url: .meta.url}' \
> "${out_dir}/${safe_title}-${uid}.meta.json"
echo " saved: ${out_file}"
done
# Commit if there are changes
if ! git diff --quiet || ! git diff --cached --quiet; then
git add dashboards meta
git commit -m "Grafana dashboards backup: $(date -Iseconds)"
git push origin "$BRANCH"
else
echo "No changes."
fi
[Unit]
Description=Backup Grafana dashboards to Git
[Service]
Type=oneshot
Environment=GRAFANA_URL=https://grafana.example.com
Environment=GRAFANA_TOKEN=glsa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_a9999999
Environment=REPO_DIR=/srv/backups/grafana-dashboards
Environment=REPO_URL[email protected]:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.git
Environment=BRANCH=main
EnvironmentFile=/etc/backup-grafana-dashboards.env
ExecStartPre=curl \
--fail \
--location \
--output /usr/local/bin/backup-grafana-dashboards.sh \
--silent \
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/backup-grafana-dashboards.sh
ExecStartPre=chmod +x /usr/local/bin/backup-grafana-dashboards.sh
ExecStart=/usr/local/bin/backup-grafana-dashboards.sh
[Unit]
Description=Nightly Grafana dashboard backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
#!/usr/bin/env bash
#usage: curl -sL https://gist.github.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/install-dashboard-backup-units.sh | bash
if [ -f /etc/systemd/system/grafana-dashboards-backup.service ] && systemctl is-active --quiet grafana-dashboards-backup; then
sudo systemctl stop grafana-dashboards-backup
fi
if [ ! -f /etc/backup-grafana-dashboards.env ]; then
sudo curl \
--fail \
--location \
--output /etc/backup-grafana-dashboards.env \
--silent \
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/backup-grafana-dashboards.env.example
fi
for unit in service timer; do
sudo curl \
--fail \
--location \
--output /etc/systemd/system/grafana-dashboards-backup.${unit} \
--silent \
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/grafana-dashboards-backup.${unit}
done
#!/usr/bin/env bash
set -euo pipefail
# --- Required env vars ---
# GRAFANA_URL="https://grafana.example.com"
# GRAFANA_TOKEN="grafana_api_token_with_admin_or_editor_rights"
# REPO_DIR="/srv/backups/grafana-dashboards"
# Optional: BRANCH="main"
: "${GRAFANA_URL:?GRAFANA_URL is required}"
: "${GRAFANA_TOKEN:?GRAFANA_TOKEN is required}"
: "${REPO_DIR:?REPO_DIR is required}"
BRANCH="${BRANCH:-main}"
cd "$REPO_DIR"
git checkout "$BRANCH"
git pull --ff-only
auth_hdr=( -H "Authorization: Bearer ${GRAFANA_TOKEN}" -H "Content-Type: application/json" )
common_curl=( -sS -f "${auth_hdr[@]}" )
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/-/g; s/[^a-z0-9-]//g; s/^-+|-+$//g'
}
echo "Loading existing folders..."
folders_now="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")"
get_folder_id_by_title() {
local title="$1"
if [[ "$title" == "General" ]]; then
echo 0; return
fi
echo "$folders_now" | jq -r --arg t "$title" '.[] | select(.title==$t) | .id' | head -n1
}
create_folder_if_missing() {
local title="$1"
[[ "$title" == "General" ]] && { echo 0; return; }
local id
id="$(get_folder_id_by_title "$title")"
if [[ -z "${id:-}" ]]; then
echo "Creating folder: $title"
resp="$(curl "${common_curl[@]}" -X POST "${GRAFANA_URL}/api/folders" \
--data "$(jq -n --arg t "$title" '{title:$t}')" )"
folders_now="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")"
id="$(echo "$resp" | jq -r '.id')"
fi
echo "$id"
}
shopt -s nullglob
for json_file in dashboards/*/*.json; do
folder_dir="$(basename "$(dirname "$json_file")")"
# Recover original folder name from meta if present (more accurate than slug)
meta_file="${json_file%.json}.meta.json"
if [[ -f "$meta_file" ]]; then
folder_title="$(jq -r '.folderTitle // "General"' "$meta_file")"
else
# fall back to deslugging folder_dir best-effort
folder_title="$(echo "$folder_dir" | sed -E 's/-/ /g')"
[[ "$folder_title" =~ ^general$ ]] && folder_title="General"
fi
folder_id="$(create_folder_if_missing "$folder_title")"
dashboard_json="$(cat "$json_file")"
payload="$(jq -n \
--argjson dash "$dashboard_json" \
--argjson fid "$folder_id" \
'{
dashboard: $dash,
folderId: ($fid|tonumber),
overwrite: true,
message: "restore"
}'
)"
echo "Restoring $(basename "$json_file") -> folder [$folder_title] (id $folder_id)"
curl "${common_curl[@]}" -X POST "${GRAFANA_URL}/api/dashboards/db" --data "$payload" >/dev/null
done
echo "Restore complete."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment