Docker compose based app able to backup google photo via takeout by link
Google makes it very hard to automate takeout management - but it is possible
- mainstream arch like
x86_64- to be able to run undetected-grid and gpth docker,docker-composecrontabor another way to schedule automation and notify if something goes wrongssmtpor another channel to notify you about backup launch status- Any storage to mount to store backup in
The app consists of two main parts:
- Backup server - runs backup script, stores results to FS, provides auth info
- Browser server - runs browser, ensures security of auth info
Browser server runs undetected-grid — patched Firefox + Selenium Grid — and exposes the WebDriver API on port 4444. You can run it on a dedicated node connected to the backup server, or on the same host.
All commands below run from the project root.
Archive downloads stream from the Grid on port 4445, encrypted with AES-256-GCM under a pre-shared key. Both servers must use the same key, or downloads fail with a GCM tag error.
Generate it once and keep the value secret (do not put it in any .env):
openssl rand -hex 32- Browser server: provide it inline when starting the Grid (see step 2).
- Backup server: set it in the scheduler environment that runs
execute_backup.sh(see step 6).
-
Put your Google account email in
browser-server/.env:USER_E=you@gmail.com
USER_Ecan be overridden inline on the command when running manual-auth — e.g. to back up a different account — and the inline value takes precedence over.env. -
Start
undetected-gridas a persistent service withFILE_STREAM_KEYset (see generation above):FILE_STREAM_KEY=<your-key> VERSION=$(uv version --short) \ docker compose --env-file .env --env-file browser-server/.env \ -f browser-server/docker-compose.yaml up -d undetected-grid
The key is read from the shell environment; it is not loaded from
.env. Afterdocker compose down && up, set it again. Images are tagged with the project version frompyproject.tomlviauv version --short.DISPLAY_MODEselects where the browser renders:virtual(default) — renders on an Xvfb virtual display; no display required.headed— renders on your real$DISPLAY; runxhost +local:on the host first (XWayland on Wayland sessions) so the container can reach your X server. To override, setDISPLAY_MODE=headedinbrowser-server/.envor inline on the command. The login is automatic;manual-authis just a one-shot sidecar. (if you run docker with sudo, see If docker requires sudo — the key is otherwise silently stripped).
-
Run
manual-authas a sidecar against the running grid to obtain auth state:USER_P=$(read -rsp "Google password: " p && echo "$p") VERSION=$(uv version --short) \ docker compose --env-file .env --env-file browser-server/.env \ -f browser-server/docker-compose.yaml --profile manual up --no-deps manual-auth
USER_Pis captured by a hiddenreadprompt and passed as an env var to compose (not written to disk or shell history). The browser runs with a visible window to avoid bot detection;DISPLAY_MODEonly selects where it renders (as noted in step 2).--no-depsis required: without it, compose reconcilesundetected-gridand, since this command doesn't passFILE_STREAM_KEY, recreates the grid (wiping the browser session and regenerating the ECIES keys). The grid must already be running from step 2. (if you run docker with sudo, see If docker requires sudo — the password is otherwise silently stripped). -
Store the auth state. When
manual-authfinishes it writesbrowser-server/browser-downloads/.auth_encoded(the values are already Grid-encrypted). Copy this file tobackup-server/.auth_encoded— it is the cookie jar the backup server later loads.cp browser-server/browser-downloads/.auth_encoded backup-server/.auth_encoded
-
Store the encoded password. Get the Grid public-key web-tool link from the
undetected-gridlogs:docker compose --env-file .env --env-file browser-server/.env \ -f browser-server/docker-compose.yaml logs undetected-grid
Look for "Encode with: https://dzharikhin.github.io/ecies/?pk=" in the logs, open that link, encode your password, and save the result as
ENCODED_PASSinbackup-server/.env.Encryption keys are generated on start by default, so after a restart you must re-run manual-auth (step 3) to regenerate
.auth_encodedand re-encodeENCODED_PASS(step 5). To keep keys stable across restarts, set fixedSK/PKinbrowser-server/.env.
If your user isn't in the docker group, prefix docker commands with sudo. But
don't write sudo VAR=value docker compose …: sudo's default env_reset
policy strips inline VAR=value assignments, so the command runs with VAR
empty. This fails silently — the Grid starts, but with FILE_STREAM_KEY blank it
skips binding port 4445 (see FileStreamPlugin.java) and the backup later dies
with Connection refused.
Wrap the assignments with env, which sets them after sudo's environment
reset, so they survive without being exported:
sudo env \
FILE_STREAM_KEY=$(read -rsp "Enter secret: " p && echo "$p") \
VERSION=$(uv version --short) \
docker compose --env-file .env --env-file browser-server/.env \
-f browser-server/docker-compose.yaml up -d undetected-grid- The secret is captured by
$(read …)and consumed inline — it is neverexported and never becomes a shell variable in your session. - The same wrapping works for
manual-auth(USER_P=$(read …)) and any otherVAR=valuecommand. - The value is briefly present in the process argv while
envruns — the same exposure your plainVAR=$(read …)form already has. If that matters, add your user to thedockergroup (sudo usermod -aG docker $USER, then log out/in) and dropsudoentirely, so the plain no-sudocommands above work as-is.
- go to backup-server
keys_RU.csv- locale-dependent button names to interact with browser UI controls. If you need another locale, see how to useGOOGLE_LANGenv param
there's no way to use locale-agnostic selectors there - css-classes are obfuscated and are changing ;(
- create
downloadsdir - it's for backup intermediate processing: downloading, unpacking, sorting, etc - can be local FS - create
photosdir - it's where final backups are stored to. If you have dedicated storage - here's convenient mount point - copy the
.auth_encodedfile produced by manual-auth (Browser server, step 4) here.auth_encodedis the rawdriver.get_cookies()output — values are already Grid-encrypted, so no external encoding is needed. - create
.envfile withENCODED_PASSset to your Grid-encoded password (Browser server, step 5)After
browser-serverkey rotation, regenerate.auth_encodedby re-running manual-auth and re-encodeENCODED_PASSvia the web tool. For stable keys, set fixedSK/PKinbrowser-server/.env. - set
FILE_STREAM_KEYin the scheduler environment (e.g. the crontab line or a systemd unit) — it must match the value the browser server started with. Then scheduleexecute_backup.shto run in the backup-server working directory frequently enough for your backup purposesexecute_backup.sh fails fast if
FILE_STREAM_KEYis unset and derives the image tag version frompyproject.tomlviauv version --short. It requires local customization (e.g. notification transport) before use. (to run with bare docker compose instead, see Running the backup with bare docker compose). - schedule command to reset browser from time to time(once a month is good enough)
from
./browser-serverlocation
execute_backup.sh is a thin wrapper: it checks FILE_STREAM_KEY, derives the
version, runs docker compose run, captures output for a mail notifier, then
tears the container down. If your scheduler already handles those pieces (or you
prefer to invoke compose directly), run from the backup-server/ directory:
FILE_STREAM_KEY=$(read -rsp "Enter FILE_STREAM_KEY: " p && echo "$p") VERSION="$(uv version --short)" \
docker compose --env-file .env --env-file backup-server/.env \
-f backup-server/docker-compose.yaml run --rm --remove-orphans backup
docker compose --env-file .env --env-file backup-server/.env \
-f backup-server/docker-compose.yaml down --volumes- The first
runbuilds thegtb-backup:$VERSIONimage fromDockerfileif it is missing; rebuild explicitly withdocker compose buildwhenever source or dependencies change. docker composeauto-loadsbackup-server/.envfrom the current directory, soENCODED_PASSand the other${VAR}interpolations resolve without--env-file.tty: truefromdocker-compose.yamlapplies automatically.--rm --remove-orphansplus the trailingdown --volumesmirrorexecute_backup.sh(backup-server/execute_backup.sh:14,25).FILE_STREAM_KEYis captured by a hiddenreadprompt (same pattern as the grid launch); it must match the value used when starting the browser server. If docker needssudo, wrap withsudo env FILE_STREAM_KEY=$(read …)(see If docker requires sudo).