gobackup-docker configures gobackup from Docker container labels
instead of a hand-written gobackup.yml.
It runs as a small supervisor next to the stock gobackup container. It watches the Docker daemon, reads
gobackup.* labels off your containers, renders a complete gobackup.yml, and writes it to a volume shared with
gobackup — which hot-reloads it automatically. Add a labeled container and it starts getting backed up; remove it and
it stops. You never edit gobackup.yml by hand.
reads gobackup.* labels
┌─────────────────┐ + a shared defaults.yml ┌──────────────────────┐
│ your containers │ ───────────────────────────▶ │ gobackup-docker │
│ (postgres, …) │ │ (this supervisor) │
└─────────────────┘ └──────────┬────────────┘
▲ Docker events (start/die) │ renders one gobackup.yml,
│ via /var/run/docker.sock:ro │ writes it atomically
└─────────────────────────────────────── ▼
┌──────────────────────┐
│ gobackup (stock) │
│ fsnotify-reloads the │
│ file, runs on cron │
└──────────────────────┘
shared config volume
The supervisor runs a single loop:
- Discover — on startup it lists running containers, then subscribes to the Docker event stream and reacts to
container
start/die(reconnecting with backoff if the stream drops). - Parse — for each container it reads the flat
gobackup.*labels into one backup model. - Merge — each model is deep-merged onto a shared profile from
defaults.yml(so credentials, retention, and schedule are written once, not on every container), then per-model overrides and opt-outs are applied. - Render — it expands
{{ .Model }}-style templates and marshals a singlegobackup.yml. - Apply — it writes the file atomically (temp + rename) to the shared volume, de-duplicating so an unchanged result never touches the file. gobackup notices the change via its own file watcher and reloads — no restart.
A burst of events (e.g. docker compose up) is debounced into a single regeneration. Docker events only
regenerate the config; the actual backups run on gobackup's own schedule.
Backups themselves — database dumps, compression, encryption, upload, retention — are done entirely by gobackup. This tool generates its configuration and, when a backup needs data that only lives in another container, recreates the gobackup container to mount that data in (archive files read-only, SQLite database files read-write — see below).
docker-compose.yml:
name: myproject
volumes:
gobackup-config: # shared: supervisor writes, gobackup reads
gobackup-state: # gobackup ~/.gobackup: retention state survives restarts
networks:
backup_net:
services:
gobackup-docker:
image: ghcr.io/ekho/gobackup-docker:latest
environment:
GOBACKUP_DOCKER_OUTPUT: /etc/gobackup/gobackup.yml
GOBACKUP_DOCKER_DEFAULTS: /etc/gobackup-docker/defaults.yml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./defaults.yml:/etc/gobackup-docker/defaults.yml:ro
- gobackup-config:/etc/gobackup
networks: [backup_net]
gobackup:
image: huacnlee/gobackup:latest
command: ["/usr/local/bin/gobackup", "run", "-c", "/etc/gobackup/gobackup.yml"]
environment:
# ${VAR} in defaults.yml / labels is expanded HERE, by gobackup — so every
# referenced secret must be present in THIS container's environment:
YC_OS_ACCESS_KEY: ${YC_OS_ACCESS_KEY}
YC_OS_SECRET_KEY: ${YC_OS_SECRET_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
NEXTCLOUD_DB_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
volumes:
- gobackup-config:/etc/gobackup
- gobackup-state:/root/.gobackup
networks: [backup_net]
# An application you want backed up — it only needs its own database labels:
nextcloud-db:
image: postgres:16
networks: [backup_net]
environment:
POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
POSTGRES_DB: nextcloud
labels:
gobackup.enable: "true"
gobackup.name: "nextcloud"
gobackup.databases.db.type: "postgresql"
gobackup.databases.db.host: "nextcloud-db"
gobackup.databases.db.database: "nextcloud"
gobackup.databases.db.username: "postgres"
gobackup.databases.db.password: "$${NEXTCLOUD_DB_PASSWORD}" # $$ keeps ${...} literal for gobackupdefaults.yml (shared building blocks, written once):
default: # the profile every model inherits
schedule:
cron: "0 1 * * *"
compress_with:
type: tgz
default_storage: s3
storages:
local:
type: local
keep: 10
path: /backups/{{ .Model }}
s3:
type: s3
bucket: media.kd
region: ru-central1
endpoint: https://storage.yandexcloud.net
access_key_id: ${YC_OS_ACCESS_KEY}
secret_access_key: ${YC_OS_SECRET_KEY}
keep: 10
path: /backups/{{ .Model }}
notifiers:
telegram:
type: telegram
chat_id: "66097481"
token: ${TELEGRAM_BOT_TOKEN}docker compose up -d, and the nextcloud-db container is backed up nightly to both S3 and local disk — with the
whole storage/notifier/schedule setup declared once in defaults.yml.
One backup model per container. Everything is under the gobackup. namespace. Four keys are reserved meta; anything
else is a config path that goes straight into the model body.
| Label | Meaning |
|---|---|
gobackup.enable |
Opt-in gate. "true" to back up this container (leniently parsed: true/1/yes/on). |
gobackup.name |
Model name — the key under models: and the value of {{ .Model }}. Defaults to <container>-<host>. |
gobackup.instance |
Scope selector: only the supervisor with a matching GOBACKUP_DOCKER_INSTANCE manages this container. |
gobackup.profile |
Which profile in defaults.yml to inherit. Defaults to default. |
gobackup.<config.path> |
Anything else → the model body, e.g. gobackup.databases.db.type, gobackup.archive.includes, gobackup.storages.s3.keep, gobackup.schedule.cron. |
gobackup.<subtree>: "!none" |
Remove an inherited subtree, e.g. gobackup.notifiers: "!none" or gobackup.storages.local: "!none". |
Because Docker Compose coerces unquoted true/no/on into booleans, quote every label value.
gobackup.archive.includes backs up file paths — but those paths must exist inside the gobackup container, not
the application container. The supervisor can automatically discover which volumes the application container uses
and ensure they are mounted into the gobackup container.
What happens: when a model has archive.includes, the supervisor:
- Inspects the source container's mounts via the Docker API.
- Finds the MountPoint that matches each archive path.
- Transforms the path →
/volumes/<model>/<original-path>(so paths from different models never collide). - Stops, removes, and recreates the gobackup container with the discovered volumes mounted read-only (
:ro).
No manual volume pre‑mounting is needed for named Docker volumes or bind mounts — the supervisor handles it.
services:
nextcloud:
labels:
gobackup.enable: "true"
gobackup.archive.includes: "/var/www/html,/etc/nginx"
gobackup.archive.excludes: "*.log,*.tmp"
# The supervisor will:
# 1. find nextcloud's volumes (e.g. nextcloud_html → /var/www/html)
# 2. add them as read-only mounts on the gobackup container
# 3. rewrite includes to /volumes/nextcloud/var/www/htmlExcludes are passed through as-is — they are glob patterns that apply inside the archive root, not paths that need mount resolution.
Recreating the gobackup container needs a spec (image, command, networks, …). By default the supervisor reuses the
settings of the container it replaces. To override them, put gobackup_container.* labels on the supervisor's own
container — they are read once at startup via the supervisor's self-inspection:
| Label (on the supervisor) | Example | Effect |
|---|---|---|
gobackup_container.image |
huacnlee/gobackup:latest |
image of the recreated container |
gobackup_container.command |
/usr/local/bin/gobackup run -c /etc/gobackup/gobackup.yml |
command — must be the full argv (the stock image has no ENTRYPOINT) |
gobackup_container.networks |
backup_net,caddy_net |
networks to attach (real network names) |
gobackup_container.env.<VAR> |
${DB_PASSWORD} |
one env var per label; when any is set it replaces the env set |
gobackup_container.labels.<key> |
com.example.tier=infra |
extra labels passed through |
Rules:
- Each field falls back to the container being replaced when its label is absent — set only what you want to change.
- Base mounts (the config volume,
/backups, state) are preserved automatically; only the auto-discovered backup-source volumes (archive includes and SQLite database files) are managed. Never list them here. - The gobackup container must already exist and carry the label
gobackup-docker.component: "gobackup"— that is how the supervisor finds the container to recreate. The supervisor recreates it; it does not create one from scratch. - The supervisor needs the Docker socket mounted (
:rois sufficient — Docker API calls are socket messages, not file writes). gobackup itself never needs the socket.
services:
gobackup-docker: # the supervisor
image: ghcr.io/ekho/gobackup-docker:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./defaults.yml:/etc/gobackup-docker/defaults.yml:ro
- gobackup-config:/etc/gobackup
labels:
gobackup_container.image: "huacnlee/gobackup:latest"
gobackup_container.command: "/usr/local/bin/gobackup run -c /etc/gobackup/gobackup.yml"
gobackup_container.networks: "backup_net"
gobackup: # recreated by the supervisor when auto-mounted volumes or credentials change
image: huacnlee/gobackup:latest
command: ["/usr/local/bin/gobackup", "run", "-c", "/etc/gobackup/gobackup.yml"]
labels:
gobackup-docker.component: "gobackup" # ← how the supervisor finds it
volumes:
- gobackup-config:/etc/gobackup
- backups:/backups
networks: [backup_net]Some gobackup fields are YAML arrays, but a Docker label is a flat string. The supervisor converts comma-separated values to proper arrays. Spaces around commas are trimmed; a single value becomes a one-element array; an empty value becomes an empty array.
labels:
gobackup.archive.includes: "/var/www/html,/etc/nginx" # → ["/var/www/html", "/etc/nginx"]
gobackup.databases.pg.tables: "users,orders" # → ["users", "orders"]The full set of array fields (this is exhaustive for gobackup — every field it reads as an array):
| Label path | Applies to | gobackup meaning |
|---|---|---|
gobackup.archive.includes |
any model with an archive block |
paths to back up |
gobackup.archive.excludes |
″ | paths to skip |
gobackup.databases.<id>.tables |
mysql, postgresql | only these tables |
gobackup.databases.<id>.exclude_tables |
mysql, postgresql, mongodb | skip these tables/collections |
gobackup.databases.<id>.exclude_tables_prefix |
mongodb | skip collections with these prefixes |
gobackup.databases.<id>.skip_databases |
mssql (with all_databases: "true") |
databases to skip |
gobackup.databases.<id>.endpoints |
etcd | deprecated upstream — prefer the singular string endpoint |
Not arrays (leave these as plain strings — do not comma-split them):
databases.<id>.args, notifier recipientsnotifiers.<id>.to(gobackup splits these itself), andstorages.<id>.credentials(a JSON blob that may contain commas). Only the paths in the table above are converted.
A value that must contain a literal comma can't be expressed this way — a rare case for table names and paths.
gobackup dumps a SQLite database by reading its file (sqlite3 <path> .dump) — so, exactly like archive paths,
that file must exist inside the gobackup container, not the application container. Declare the database by the
path it has in your application and the supervisor mounts it into the engine for you:
services:
bot:
volumes:
- botdata:/app/data # the app's own data volume
labels:
gobackup.enable: "true"
gobackup.name: "shop"
gobackup.databases.main.type: "sqlite"
gobackup.databases.main.path: "/app/data/bot_database.sqlite3"What happens: for each type: sqlite database with a path, the supervisor:
- Inspects the source container and finds the volume (or bind mount) backing that path.
- Rewrites
databases.<id>.path→/volumes/<model>/<original-path>(so paths from different models never collide). - Recreates the gobackup container with that volume mounted — read-write.
The mount is read-write, unlike read-only archive mounts: sqlite3 <path> .dump opens the database, and a
WAL-mode database writes -wal/-shm sidecar files next to it on open. The volume's directory is mounted (not
just the file), so those sidecars have somewhere to go.
Uses the same recreation machinery as archive auto-mount — the
gobackup_container.*spec and thegobackup-docker.component: "gobackup"marker apply the same way (see above). Apathalready under/volumes/, or one not backed by any volume, is left untouched (with a log). The stockgobackupimage already bundlessqlite3, so no extra setup is needed.
- With
gobackup.name→ used verbatim (e.g.mynextcloud). - Without it →
<container-name>-<host>, wherehostis the Docker daemon hostname (so backups from different hosts writing to the same storage don't collide). Override the host part withGOBACKUP_DOCKER_HOST_ID.
The model name also feeds {{ .Model }}, so storage paths like path: /backups/{{ .Model }} become
/backups/nextcloud, /backups/gitea-prod-db-1, etc.
Under Docker Compose the container name is <project>-<service>-<n> (e.g. myproject-gitea-1), which makes auto
names verbose. Set gobackup.name (or a fixed container_name) for tidy, stable model names.
{{ .Model }}(also{{ .Container }},{{ .Host }},{{ .Instance }}) are Go-template tokens expanded by the supervisor before the file is written. Use them for names/paths.${VAR}(brace form) is left untouched by the supervisor and expanded by gobackup at load time (viaos.ExpandEnvover the whole config file, plus an auto-loaded sibling.env). Use it for secrets — keep credentials in env, never baked into a label.
Two practical rules for ${VAR}:
- The variable must exist in the gobackup container's environment (that is where expansion happens), not the application/database container's.
- In a Compose file, write
$${VAR}or$$VARin the label — Compose collapses$$→$, leaving${VAR}/$VARfor gobackup to expand (both the braced and bare forms are recognized as references). A single$would be interpolated (and likely blanked) by Compose itself.
Never write ${MODEL} for the model name: gobackup would treat it as an environment variable and blank it. That is
why the supervisor uses {{ ... }}, and only for its own substitutions.
gobackup expands variables by running os.ExpandEnv over the entire config file text before parsing it. So any
$ in any value — most often a password like m9qq!$7v!s^$!UU — would be eaten as a variable reference and
silently corrupted (→ m9qq!v!s^UU, an auth failure you'd chase for hours). YAML quoting can't prevent it: expansion
happens on the raw bytes before the parser sees the quotes, and os.ExpandEnv has no escape character ($$→``,
\$→``).
So the supervisor keeps a $ only when it begins a real variable reference — $NAME or ${NAME}, where NAME is
a letter followed by one or more letters/digits/underscores. Every other $ is rewritten to ${GB_DOLLAR}, a sentinel
variable the supervisor sets to a literal $ in the engine's environment; gobackup then expands ${GB_DOLLAR} back to
$, so the value round-trips exactly. Put a $-containing password like m9qq!$7v!s^$!UU straight in a label and it
just works — no escaping gymnastics for the value itself (Compose-level $$ is a separate layer, applied to how Compose
reads your file).
Consequences:
- Reference-shaped
$still expands; everything else is kept literal.$DB_PASSWORDand${DB_PASSWORD}are left as-is and expanded by gobackup;$7,$!, a trailing$,$$, and even a too-short name like$Aor${A}are all preserved as a literal$. - This needs the supervisor to manage the engine (Docker socket +
gobackup-docker.component=gobackup), because it injects theGB_DOLLARsentinel. In pure label-only mode it can't, so it instead logs a warning naming each value with a to-be-escaped$— resolve those with a*_env/*_filecredential label (see below) or by writing the value through your own${VAR}. - A
$that looks like a reference is treated as one. Because$word/${word}are, by definition, the reference syntax, a secret that genuinely contains such a sequence (e.g.p$word,Se${cret}) is indistinguishable from a real variable — the supervisor leaves it alone and gobackup expands it (to empty if unset). Supply such values via a*_env/*_filecredential label, which never round-trips throughos.ExpandEnv.
Instead of writing a credential inline (or wiring ${VAR} yourself), a client container can declare where the value
lives and let the supervisor bridge it into the engine. Add an _env or _file suffix to a credential key:
labels:
gobackup.databases.main.password_env: "DB_PASSWORD" # value = $DB_PASSWORD in THIS container's env
# or
gobackup.databases.main.password_file: "/run/secrets/db_pw" # value = a Docker secret file in THIS containerSupported credential keys: password, token, secret, access_key, secret_key (and the same under
storages.<id>.* / notifiers.<id>.*). credentials_file (gobackup's own GCS keyfile key) is not affected.
Set at most one of the inline key, _env, or _file — a conflict or an unresolved value skips the model (fail-closed).
Both render the config with a placeholder only — the plaintext is never written to gobackup.yml:
databases:
main:
password: ${GB_<MODEL>_DATABASES_MAIN_PASSWORD}How each is resolved (the engine container is recreated to apply it):
| Source | How the value reaches gobackup | Plaintext exposure | Rotation |
|---|---|---|---|
_env |
supervisor reads the var from the client container's env and sets it in the engine's env | visible in docker inspect of the engine (it was already an env var) |
needs a recreate to change |
_file |
supervisor re-mounts the secret's host file into the engine (RO) and a command wrapper cats it into the env at start |
none — not in gobackup.yml, not in docker inspect (only the path shows); the supervisor never reads it |
re-read on each config reload |
Notes & limits:
_fileworks with Composefile:secrets (they have a host bind source). Swarmdocker secretand Composeenvironment:secrets have no re-mountable host source → skipped with a log (use_env, or a bind-mounted file).- The
_filevalue is injected into the daemon's process env, so scheduled/API-triggered backups see it. Agobackup performrun viadocker execstarts a fresh process and will not — trigger_file-credential backups through the schedule or the control-planePOST /api/perform, notdocker exec. - Requires the supervisor to manage the engine (Docker socket +
gobackup-docker.component=gobackupon it), same as archive auto-mount.
A container inherits the profile and changes only what differs:
labels:
gobackup.enable: "true" # no gobackup.name → model "gitea-<host>"
gobackup.databases.db.type: "postgresql"
gobackup.databases.db.host: "gitea-db"
gobackup.storages.s3.keep: "90" # override just this key (deep-merged over the profile)
gobackup.notifiers: "!none" # drop the inherited notifiers for this modelThe supervisor-only defaults.yml (path from GOBACKUP_DOCKER_DEFAULTS) holds named profiles — a "default model
body" every container inherits. gobackup never reads this file.
- You may use YAML anchors and merge keys (
&anchor,<<: *anchor) inside it — they are resolved when the file is parsed and never leak into labels. Migrating an existing anchor-basedgobackup.ymlis close to copy-paste. - Define more than one profile and select it per container with
gobackup.profile. - The supervisor also watches this file: edit it and the config is regenerated. A malformed edit is ignored and the last good config is kept.
Merge precedence (low → high):
- gobackup's own built-in defaults;
- the profile body from
defaults.yml(default, or the one named bygobackup.profile); - the container's
gobackup.<config.path>labels (deep-merged, per-key); "!none"opt-outs (applied last, delete the named subtree);{{ ... }}template expansion over the merged result (a rendering pass, not a value source).
Two applications, sharing the defaults.yml above:
nextcloud-db:
labels:
gobackup.enable: "true"
gobackup.name: "nextcloud" # explicit name
gobackup.databases.db.type: "postgresql"
gobackup.databases.db.host: "nextcloud-db"
gobackup.databases.db.database: "nextcloud"
gobackup.databases.db.username: "postgres"
gobackup.databases.db.password: "$${NEXTCLOUD_DB_PASSWORD}"
gitea-db:
container_name: gitea-db # fixed name → auto model "gitea-db-<host>"
labels:
gobackup.enable: "true" # no gobackup.name → "<container>-<host>"
gobackup.databases.db.type: "postgresql"
gobackup.databases.db.host: "gitea-db"
gobackup.storages.s3.keep: "90" # keep 90 backups on S3 (not the default 10)
gobackup.notifiers: "!none" # no notifications for this oneThe supervisor renders (host = prod-db-1):
models:
gitea-db-prod-db-1:
compress_with:
type: tgz
databases:
db:
host: gitea-db
type: postgresql
default_storage: s3
schedule:
cron: 0 1 * * *
storages:
local:
keep: 10
path: /backups/gitea-db-prod-db-1
type: local
s3:
access_key_id: ${YC_OS_ACCESS_KEY}
bucket: media.kd
endpoint: https://storage.yandexcloud.net
keep: 90
path: /backups/gitea-db-prod-db-1
region: ru-central1
secret_access_key: ${YC_OS_SECRET_KEY}
type: s3
nextcloud:
compress_with:
type: tgz
databases:
db:
database: nextcloud
host: nextcloud-db
password: ${NEXTCLOUD_DB_PASSWORD}
type: postgresql
username: postgres
default_storage: s3
notifiers:
telegram:
chat_id: "66097481"
token: ${TELEGRAM_BOT_TOKEN}
type: telegram
schedule:
cron: 0 1 * * *
storages:
local:
keep: 10
path: /backups/nextcloud
type: local
s3:
access_key_id: ${YC_OS_ACCESS_KEY}
bucket: media.kd
endpoint: https://storage.yandexcloud.net
keep: 10
path: /backups/nextcloud
region: ru-central1
secret_access_key: ${YC_OS_SECRET_KEY}
type: s3Note how gitea-db dropped notifiers and bumped S3 keep to 90, while nextcloud kept the full inherited setup —
and neither container repeated the S3 credentials, telegram token, or schedule.
| Variable | Default | Meaning |
|---|---|---|
GOBACKUP_DOCKER_OUTPUT |
/etc/gobackup/gobackup.yml |
Where to write the generated config (a volume shared with gobackup). |
GOBACKUP_DOCKER_DEFAULTS |
/etc/gobackup-docker/defaults.yml |
The shared profiles file. Missing file = label-only mode. |
GOBACKUP_DOCKER_INSTANCE |
(empty) | Only manage containers with a matching gobackup.instance (or none). Lets multiple supervisors coexist. |
GOBACKUP_DOCKER_HOST_ID |
(daemon hostname) | Host suffix for auto-generated model names. |
GOBACKUP_DOCKER_EXPOSED_BY_DEFAULT |
false |
If true, back up every container unless it sets gobackup.enable=false. Default is strict opt-in. |
GOBACKUP_DOCKER_DEBOUNCE |
500ms |
How long to coalesce a burst of Docker events before regenerating. |
GOBACKUP_DOCKER_HTTP_ADDR |
(empty) | If set (e.g. :2705), serve the control-plane API. |
GOBACKUP_DOCKER_GOBACKUP_URL |
(empty) | gobackup's web API base URL (e.g. http://gobackup:2703); enables the "backup now" proxy. |
The Docker connection is taken from the environment (DOCKER_HOST, TLS, etc.), so rootless and remote Docker work
without hardcoding the socket path.
Set GOBACKUP_DOCKER_HTTP_ADDR to expose the supervisor's own state and an on-demand trigger:
| Endpoint | Description |
|---|---|
GET /healthz |
Liveness check. |
GET /status |
JSON: instance, host id, discovered container/model count, last render time, last error. |
POST /api/perform?model=<name> |
Trigger a backup now — proxied to gobackup's POST /api/perform. Requires GOBACKUP_DOCKER_GOBACKUP_URL, and only forwards models the supervisor actually rendered. |
Example:
curl http://localhost:2705/status
curl -X POST "http://localhost:2705/api/perform?model=nextcloud"gobackup's own API on :2703 (/metrics, /api/config, /api/log) remains available directly. Keep both on an
internal network.
- Databases over the network (postgresql, mysql, mongodb, …): the supervisor and the database share a Docker
network, and the label
hostis the database's service name. gobackup connects over the network and runs the native dump tool (pg_dump,mysqldump, …), all of which are bundled in the stockgobackupimage. These dumps need no access beyond the network. (File-based engines like SQLite are the exception — see the next bullet.) - File / volume backups (
gobackup.archive.includes): the supervisor automatically discovers the source container's Docker volumes and bind mounts, and ensures they are mounted into the gobackup container. Paths are rewritten to/volumes/<model>/to prevent collisions. No manual volume pre‑mounting is required — just add the labels. See Archive file backups with automatic volume mounting for details. - SQLite databases (
gobackup.databases.<id>.type: sqlite): file-based, not network. The supervisor mounts the DB file's backing volume into the engine read-write and rewritespathto/volumes/<model>/…, just like archive files. See SQLite databases by file path.
Mounting /var/run/docker.sock — even read-only — grants broad access to the Docker daemon, and thus the host. Only
the small supervisor needs it (for discovery); gobackup does not. To harden, put a read-only
docker-socket-proxy in front of it, restricted to container
list/inspect, and keep the whole stack on an internal network. Prefer the network+DSN path for databases so the
socket is used only for discovery.
MIT © Boris Gorbylev. Use, modify, and distribute freely — the only condition is that the copyright notice is retained in copies and substantial portions of the software (attribution may not be removed).