Why Your Secure Folders Became 0755 After Migration
Directory permissions are easy to overlook during migration. If your tool creates directories before copying files, those directories probably have default permissions, not the ones you set.
You migrated your file server. Sizes match. Checksums pass. You sign off on the project.
Three weeks later, someone notices that /srv/confidential, which was mode 0700 on the old server, is now 0755 on the new one. Any local user can read it. The files inside have the right permissions. The folder itself doesn’t.
This is more common than you’d think, and almost nobody checks for it.
Why directories lose their permissions
Most migration tools, scripts, and even hand-rolled cp commands follow the same pattern:
- Create the destination directory
- Copy files into it
- Maybe set file metadata afterward
Step 1 is the problem. When a tool creates a directory, it needs some mode. The near-universal default is 0755. The directory is a container, a thing that needs to exist before files can go into it. Permission preservation is an afterthought, if it’s a thought at all.
Here’s what that looks like in practice:
# What most scripts do internally
mkdir -p /dest/srv/confidential # created as 0755
cp source_files... /dest/srv/confidential/
# done. Never looked at the source directory's mode.
# What actually existed on the source
drwx------ root root /srv/confidential # 0700
drwxr-x--- root staff /srv/finance # 0750
drwxrwx--- root dev /srv/shared-builds # 0770
After migration, every single one of those is 0755. World-readable.
Security impact
A directory with permissions 0755 means every user on the system can list its contents and read files inside it (assuming file permissions allow). If the source had 0700, that access was intentionally restricted. Your migration just opened it up.
How to check right now
Compare directory permissions between source and destination with a single find command:
# On the source
find /srv -type d -exec stat -c '%a %n' {} \; | sort > /tmp/source-dirs.txt
# On the destination
find /dest/srv -type d -exec stat -c '%a %n' {} \; | sort > /tmp/dest-dirs.txt
# Compare (strip the path prefix first)
diff <(sed 's| /srv| |' /tmp/source-dirs.txt) \
<(sed 's| /dest/srv| |' /tmp/dest-dirs.txt)
If every line in the diff shows 0755 on the right side, your tool didn’t preserve directory permissions.
For a quick spot check on a single directory:
stat -c '%a' /source/path/to/directory
stat -c '%a' /dest/path/to/directory
If those two numbers don’t match, you have a problem.
The parent directory trap
This one is subtle. Even tools that do preserve permissions on “known” directories often miss intermediate parents.
Say your source has this structure:
/data/projects/client-a/reports/2025/
Your tool sees /data/projects/client-a/reports/2025/quarterly.pdf in the file list. It needs the full directory tree to exist before it can write that file. So it does the equivalent of:
os.MkdirAll("/dest/data/projects/client-a/reports/2025", 0755)
mkdir -p /dest/data/projects/client-a/reports/2025
Every directory in that chain gets created with 0755. Even if the tool later comes back to set permissions on /dest/data/projects/client-a/, the intermediate directories (/data, /data/projects) may never get corrected because they weren’t in the scan results as explicit entries.
mkdir -p always uses defaults
Both Go’s os.MkdirAll and shell mkdir -p create intermediate directories with the default mode (masked by umask). They don’t accept per-level modes. Any tool that uses these functions to create parent directories on the fly will produce 0755 parents regardless of what the source had.
What rsync does right (and what it misses)
rsync with the -a flag does handle this, mostly:
rsync -a /source/ /dest/
The -a flag includes -p (preserve permissions), which applies to both files and directories. After the transfer, rsync makes a second pass to set directory permissions. This is necessary because directories must be writable during the copy, then locked down afterward.
Where rsync can still miss:
- Interrupted transfers. If rsync dies mid-run, directories created so far keep their temporary
0755mode. The permission-fixup pass never ran. - Exclude filters. If you exclude certain files but not the directory itself, rsync may create the directory without ever processing its metadata.
- Partial syncs. Running rsync with
--ignore-existingor--updatemay skip the directory metadata pass for directories it considers “already done.” - NFS mount umask. Some NFS server configurations enforce a umask that silently clamps permissions. rsync reports success, but the server stored something different.
Verify after rsync too
Even with rsync -a, run the find ... stat comparison above. Don’t assume. It takes 30 seconds and can save you from a permissions audit failure weeks later.
The three things that need preserving
Directory permissions are just one piece. A complete migration preserves three attributes for every directory:
| Attribute | What it controls | Common default |
|---|---|---|
| Mode (permissions) | Who can read, write, enter | 0755 |
| Ownership (uid/gid) | Which user and group owns it | root:root |
| Timestamps (mtime) | When it was last modified | Time of migration |
Most tools that miss permissions also miss timestamps. Ownership is preserved slightly more often because tools running as root tend to use chown for files and directories alike. But timestamps on directories are frequently ignored because they’re considered cosmetic.
They’re not cosmetic if you have monitoring or compliance tools that track “last modified” dates on directory structures.
syncopio advantage
syncopio preserves permissions, ownership, and timestamps on every directory, including intermediate parent directories created during transfer. It applies directory metadata after all files are written, so restrictive permissions don’t block the copy. No second pass, no manual fixup.
Post-migration permissions checklist
After any migration, regardless of the tool you used:
1. Compare directory modes
find /source -type d -exec stat -c '%a %n' {} \; | sort > /tmp/src.txt
find /dest -type d -exec stat -c '%a %n' {} \; | sort > /tmp/dst.txt
diff /tmp/src.txt /tmp/dst.txt
2. Check for 0755 everywhere
# This finds directories that are 0755 on dest but NOT 0755 on source
# (i.e., something changed)
find /dest -type d -perm 0755 -exec stat -c '%n' {} \;
If the list is suspiciously long, and your source had varied permissions, the tool used defaults.
3. Check ownership
find /source -type d -exec stat -c '%U:%G %n' {} \; | sort > /tmp/src-own.txt
find /dest -type d -exec stat -c '%U:%G %n' {} \; | sort > /tmp/dst-own.txt
diff /tmp/src-own.txt /tmp/dst-own.txt
4. Check restrictive directories specifically
These are the ones that matter most:
# Find directories that SHOULD be restrictive (not world-readable)
find /source -type d ! -perm -o=r -exec stat -c '%a %n' {} \;
# Now check if the destination matches
5. Spot check timestamps
stat -c '%y %n' /source/some/directory
stat -c '%y %n' /dest/some/directory
If the destination timestamp is the date of your migration instead of the original date, timestamps weren’t preserved.
Fix it after the fact
If you’ve already migrated and need to repair permissions:
# Generate a fixup script from the source
find /source -type d -exec stat -c 'chmod %a "%n"' {} \; \
| sed 's|/source|/dest|' > fixup-perms.sh
# Review it first
less fixup-perms.sh
# Apply
bash fixup-perms.sh
For ownership:
find /source -type d -exec stat -c 'chown %U:%G "%n"' {} \; \
| sed 's|/source|/dest|' > fixup-owners.sh
bash fixup-owners.sh
This works, but it’s a band-aid. The right solution is using a tool that preserves this metadata during the initial transfer.
Further reading: