I moved off Google Photos onto Synology Photos. My NAS is a DS225+, which runs Container Manager, so the migration never needed a laptop in the loop.
Synology’s Cloud Sync could have pulled the export directly, but that means authorizing the NAS against my personal Google account and leaving a long-lived credential on a device I will not audit again for a year. For a one-time migration, that is a permanent risk for a temporary convenience. So I downloaded the Takeout archives to my laptop and uploaded them to the NAS instead.
That left 200 GB of zips — 21 archives, ~10 GB each — sitting on the Synology, and no good way to work on them:
- The laptop cannot hold them. 200 GB of archives plus their extracted output is more free disk than I have.
- Small files, not bytes, are the bottleneck. Extracted, Takeout is hundreds of thousands of JPEGs, HEICs, MP4s, and one JSON sidecar per file. Copying that over SMB is dominated by per-file round trips. Extracting locally and copying back is the worst possible shape of transfer.
The data was already on the NAS. The compute had to come to it.
Not just extraction, either. Takeout does not write your metadata back into your files — it strips capture time, GPS, and album data into .json sidecars. Import as-is and Synology Photos sorts your library into nonsense. Fixing that means many passes over the data, next to the disks.
So: a small Docker image giving me an SSH session on the NAS with the Takeout volume mounted. This post covers the container and unpacking the archives. Part 2 covers the sidecars and EXIF repair.
graph LR
A[Laptop] -->|SSH 2222| B[Synology DS225+]
B --> C[Container: ssh_jump_box]
C --> D[sshd]
C --> E[7-Zip]
C -->|bind mount| F[Takeout share]
Prerequisites #
- Synology NAS on DSM 7.x with Container Manager
- SSH temporarily enabled on the NAS (Control Panel → Terminal & SNMP) — we turn this back off at the end
- An account in the
administratorsgroup - Free space equal to the extracted output, on top of the archives
DSM 7.2+ ships Compose v2, so it is docker compose, not docker-compose.
Step 1: The Dockerfile #
|
|
linuxserver/openssh-server handles host key generation, dropping to a non-root user, and mapping that user to an arbitrary host UID/GID. The base is Alpine, so it is apk, not apt-get — the most common copy-paste failure here.
p7zip installs /usr/bin/7z. You will see variants that also install unzip; it is redundant, since 7z x reads ZIP and 7z t tests integrity. Part 2’s tooling gets appended to this same line.
Step 2: The compose file #
|
|
No version: key — it is obsolete in Compose v2.
| Setting | Why it matters |
|---|---|
PUID / PGID |
Must match the DSM account owning your files, or you extract 200 GB that DSM cannot read. 100 is users; the first DSM user is usually 1026. Verify with id your_username. |
PASSWORD_ACCESS + PUBLIC_KEY_FILE |
Both enabled. sshd accepts either — key for daily use, password as a way in from a machine without the key. |
USER_PASSWORD |
password@123 is a placeholder. Change it before first start. The space before # change me is what makes it a comment. |
ssh_config:/config |
A named volume, not a Synology path. Host keys land here, and DSM’s ACLs leave them group-readable, so sshd refuses to start — presenting as Connection closed by remote host. |
SUDO_ACCESS=false |
Nothing here needs root. |
Step 3: Generate a key and deploy #
|
|
Copy the .pub, never the private key.
From the CLI:
|
|
Three lines confirm it worked: sshd is listening on port 2222, User/password ssh access is enabled., and Public key from file added.
From Container Manager (DSM 7.2; labels shift between versions):
- Container Manager → Project → Create
- Name it
ssh_jump_box, set Path to the folder above - Source → Use existing docker-compose.yml
- Next past the web-portal step, then Done
Day to day, Project → select → Action handles Start / Stop / Restart. After editing the compose file or Dockerfile use Action → Build — a plain Start reuses the stale image and silently ignores your edits. Container → Details → Log shows the sshd output, and Container → Terminal gives you a browser shell if you lock yourself out (as root, which matters below).
Step 4: Connect #
|
|
Verify before doing anything else:
|
|
911 means PUID never applied — those vars are read at container creation, so it needs a rebuild, not a restart.
On space: extracted Takeout is roughly the same size as the archives, since photos and video are already compressed and ZIP achieves nothing on them. Budget a full second copy.
Step 5: Extract #
Google Takeout’s numbered parts are independent ZIP files, not split volumes — -002.zip is a complete archive holding a different subset. Extract each on its own; a corrupt part costs you that part, not the set.
|
|
Why each piece:
- Glob, do not count. Your part count depends on the archive size you picked at export. Worse,
{1..21}does not work here at all — the shell is BusyBoxash, which passes brace expansions through literally, so the loop matches nothing and fails quietly enough that you walk away thinking it started. nohup … &— a 200 GB job outlasts every laptop lid.-y— otherwise an overwrite prompt blocks a detached job forever.- Exit status and
dfper part — across 21 parts one truncated download is realistic, and you want it named in the log, with a running space forecast beside it. - Log to
/backup, not/tmp— container/tmpdoes not survive a restart.
Watch it with tail -f /backup/extraction.log. If space is tight, swap the OK: line for rm -f "$file" to delete each archive as it succeeds.
Debugging file permissions #
This cost me the most time. Nearly every confusing failure traces to one cause: three identities write to that folder and disagree.
| Identity | Comes from | Writes as |
|---|---|---|
| DSM user | File Station, SMB, Synology Photos | 1026:100 |
| Container user | PUID / PGID |
whatever you set |
| root | Container Manager terminal, docker exec |
0:0 |
Compare numeric IDs, always. The container has no view of DSM’s user database, so the same UID shows a different name on each side and ls -l misleads you. Use ls -ln inside the container and on the NAS, and compare numbers.
| Symptom | Cause and fix |
|---|---|
| Connections close instantly | /config bind-mounted to a Synology path. Use a named volume, then docker compose down -v so keys regenerate. |
| Files extract, DSM cannot read them | PUID/PGID mismatch. Fix compose, rebuild, then sudo chown -R 1026:100 /volume1/homes/YOUR_USER/GooglePhotosBackup. |
| Root-owned files you cannot delete | Created via Container Manager’s terminal or docker exec without -u. Use docker exec -u 1026:100, then chown -R. |
| Right owner, still denied | A DSM ACL over the POSIX bits. Check sudo synoacltool -get <path>, fix in Control Panel → Shared Folder → Edit → Permissions. |
Ten seconds of preflight beats an afternoon of this:
|
|
Turn DSM SSH back off #
Do this once the container is running. DSM’s own SSH was only needed to deploy, and it is by far the most dangerous thing in this setup: it logs in as an administrator with full access to every volume, every share, and sudo. The container’s sshd is the opposite — unprivileged, no sudo, and it can see exactly one folder.
Control Panel → Terminal & SNMP → uncheck Enable SSH service → Apply.
Your workspace keeps running. Port 2222 into the container is unaffected, Container Manager handles start and stop from the UI, and its built-in terminal covers anything else. Re-enable DSM SSH only when you actually need it, and turn it off again after.
Other security notes #
- Do not commit the password. Once it is in git history, editing it later does not help. Use a gitignored
.env, orUSER_PASSWORD_FILEpointing at a mounted file. - Drop to key-only once the key works: set
PASSWORD_ACCESS=falseand rebuild. - Do not forward 2222 through your router. LAN or VPN only. A non-standard port buys nothing against a scanner.
- Mount the narrowest path that works. Mounting
/volume1hands anything that gets in your entire NAS. - Stop the container between sessions —
Action → Stop. A stopped container has no listening port.
Note that keys are only ever appended to authorized_keys. Deleting PUBLIC_KEY_FILE from your compose file does not revoke that key — it is still in the named volume. To actually revoke, edit the file or down -v and rebuild.
Cleanup #
|
|
Extracted photos are untouched — they live on the bind mount, not the volume. To remove the project directory, read the path character by character before pressing enter; a typo in sudo rm -rf against /volume1 is not something the recycle bin will help with.
What’s next #
200 GB never crossed the network again, and DSM stayed stock — no Entware, no community package to break on the next upgrade, just two short files. Swap p7zip for ffmpeg, rsync, or exiftool and the same disposable workspace handles a different job against data already on the NAS.
Part 2 picks up where the zips end: what Takeout actually gives you, why timestamps and GPS are wrong on import, how the .json sidecars map onto media files, and how to write that metadata into EXIF before Synology Photos ever sees it.