Silent Overwrites on Upload: Three File Copying Traps
The most dangerous deployment mistakes are not the ones that bring a site down, because I notice an outage immediately. The more dangerous ones are where every command returns exit code 0 and every page returns 200, but what is inside is not what I intended to upload.
I needed to update a client's bilingual website, a plain PHP site with 26 pages. The Thai pages sit at the web root, with English versions mirroring them in the en/ folder, all with the same filenames. I finished editing both languages and uploaded two files together. It took less than ten seconds, without a single error.
Two days later, the client told me the English page was in Thai.
I have already covered writing scripts on Windows to run on Linux, including paths left over from my own machine and stray files left on the server, in Writing Scripts on Windows to Run on Linux. This article is solely about silent damage when copying files: cases where the files really were uploaded successfully and the tool really did report success, but the result on the host was not what I intended.
Trap One: When the Destination Is a Folder, the Filename Is the Basename
The command that broke things looked like this. I have removed the authentication details and the real hostname, leaving just the command structure.
# ผิด: ปลายทางลงท้ายด้วย / คือโฟลเดอร์
pscp -P 22 -i deploy.ppk \
site/index.php site/en/index.php \
[email protected]:domains/example.com/public_html/
scp and pscp follow the same rule: if the destination is a folder, they name the destination file using just the source filename, discarding the entire source folder structure. There is no --relative option.
The result is that both files land at the same location, public_html/index.php. The second one on the command line overwrites the first. The program reports two successful transfers, both reach 100%, and the exit code is zero, without a single warning.
This site has several pairs of files with matching names across the two languages, including index.php, sitemap.php, and several article files in subfolders. That means this trap is waiting every time I edit both languages together, which is normal for a bilingual site.
The fix is straightforward: always specify the full destination filename. Never leave the destination as just a folder when sending multiple files together.
# ถูก: ปลายทางเป็นชื่อไฟล์เต็ม หนึ่งคำสั่งต่อหนึ่งไฟล์
pscp -P 22 -i deploy.ppk site/index.php \
[email protected]:domains/example.com/public_html/index.php
pscp -P 22 -i deploy.ppk site/en/index.php \
[email protected]:domains/example.com/public_html/en/index.php
These tools have an option for putting a password directly on the command line, and it is convenient enough to become a habit. But that command line appears in shell history, script logs, and process listings visible to other people on the same machine. Use a key. If a password is truly necessary, read it from an environment variable or a file with tightly restricted permissions. Do not type it directly into the command, and do not paste it into an article or a repo.
Symptoms on the Live Site: Every Page Returns 200, but the Content Is in the Wrong Language
What lets this trap go unnoticed for so long is that it produces no symptoms the monitoring tools can detect.
- No 404, because all the files are there
- No 500, because the PHP in the file is perfectly valid
- The error log is empty
- Uptime monitoring reports everything green, 100%
The only symptom is that a reader opens an English page and sees Thai. The footer at the bottom is also in the wrong language, because the page chooses which footer to include. Once the file is overwritten, it brings everything from the other language along with it, including a language switch button that points back to itself.
I caught it by comparing content, not status. The right check after every deployment is to request the important URLs and verify whether the content actually contains the words that should be there.
# ตรวจว่าได้ 200 แล้วยัง "มีข้อความที่คาดว่าจะมี" ด้วย
for u in "" "en/" "en/contact"; do
code=$(curl -s -o /tmp/page.html -w "%{http_code}" "https://example.com/$u")
hit=$(grep -c "Contact us" /tmp/page.html)
echo "/$u http=$code marker=$hit"
done
A page under /en/ with zero English markers is a page that has been overwritten. Checking only http=200 will definitely miss it.
Trap Two: The Mirror That Skips Files Starting with a Dot
The second trap is on my own machine. When syncing the working folder to XAMPP for testing before uploading, I used robocopy in mirror mode.
robocopy "$src" "$dst" /MIR /XD assets\img\sources /NFL /NDL /NJH /NJS /NP
# /MIR ไม่ได้คัดลอก .htaccess มาให้สม่ำเสมอ ต้องสั่งคัดลอกเองต่างหาก
Copy-Item "$src\.htaccess" "$dst\.htaccess" -Force
The result was that .htaccess sometimes arrived and sometimes did not. Apache then returned 403 with a message along the lines of being unable to read the htaccess file. That message sends me looking into file permissions when the real cause is that the file is not there.
The actual cause is not the dot at the start of the filename, because a leading dot has no special meaning on NTFS. What matters is the Hidden and System attributes attached to the file. Files extracted from a zip, checked out, or previously copied from another machine may carry the H attribute without me knowing. The same source with different attributes produces different results, which explains the "sometimes."
What made it worse was the /NFL /NDL /NJH /NJS /NP set I added to keep the screen clean. It suppresses all reporting. A run that copies nothing and a run that copies everything look exactly the same on screen.
The first is a missing file when the configuration above requires it to exist, resulting in 403 across the whole site. The second is a file that exists but is 0 bytes. This is worse, because the homepage still returns 200 normally and all the menus appear, but every URL that relies on rewrite becomes a 404. Anyone testing only the homepage will see nothing wrong.
This site uses extensionless URLs throughout, and all of them depend on this one file.
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.*)$ $1.php [L]
RewriteCond %{THE_REQUEST} \s/+(.+?)\.php([\s?])
RewriteRule ^ /%1 [R=301,L]
</IfModule>
Whenever it disappears, every internal link on the site breaks, while the homepage can still be served through DirectoryIndex as usual.
Another thing that easily sends diagnosis in the wrong direction is that many hosts now have nginx in front of Apache. When the response header says nginx, people often conclude that the host does not read .htaccess anyway and stop considering that file, even though Apache behind it is still reading it.
The 0-Byte File That Every Tool Reports as a Success
I have encountered a 0-byte file that brought an entire site down, and the cause was one harmless-looking command line.
# ผิด: 'wb' ตัดไฟล์เหลือ 0 ไบต์ ก่อนที่ตัวอ่านข้างในจะได้อ่าน
open(p, 'wb').write(open(p, 'rb').read().replace(b'\r', b''))
# ถูก: อ่านให้จบก่อน ปิดไฟล์ แล้วค่อยเขียนทับ
data = open(p, 'rb').read().replace(b'\r', b'')
open(p, 'wb').write(data)
The incorrect version produces a perfectly empty file. When I upload that empty file, every step reports success, because transferring a 0-byte file is still a successful transfer.
The rule I have followed since then is to check the file size every time I touch a file, both on my own machine and on the host. I do not even need to inspect the contents; I just check that the size is not zero and has not changed from what it should be.
One more thing: stripping CR from every file indiscriminately is usually unnecessary. Apache reads .htaccess with Windows line endings just fine. What actually needs CR removed is shell scripts that will run on Linux, because a CR on the shebang line causes an interpreter-not-found error.
Trap Three: 26 Files = 26 Handshakes
The last trap does not damage anything, but it pushes people toward a riskier choice without them realizing it.
This site has 26 pages, and several of my helper scripts edit all 26 files at once, such as the footer replacement script, the logo replacement script, and the icon insertion script. Uploading one file at a time means opening a new SSH session 26 times.
Each connection involves establishing TCP, exchanging keys, authenticating, opening a file transfer subsystem, and then closing it all, even though the combined file contents total less than a hundred kilobytes. The time spent is entirely handshake overhead, about 2-3 seconds per file.
Once it gets that slow, people start cutting corners and go back to sending multiple files into a folder in one command, which is exactly trap one. These two traps feed each other.
Bundle Everything into One Archive to Solve Two Traps at Once
The method I use now is to bundle everything into one archive and extract it at the destination.
tar czf ./out.tar.gz -C ./ site/.htaccess site/includes site/index.php site/en
pscp -P 22 -i deploy.ppk ./out.tar.gz [email protected]:~/out.tar.gz
plink -ssh -P 22 -i deploy.ppk -batch [email protected] \
"cd domains/example.com/public_html \
&& tar xzf ~/out.tar.gz --strip-components=1 \
&& rm ~/out.tar.gz && echo DEPLOYED"
This also solves trap one, because tar preserves the complete relative paths. site/index.php and site/en/index.php are separate entries in the same archive, so their names cannot collapse into a collision. The --strip-components=1 option removes the site/ prefix during extraction so the files land directly at the web root.
I wrote down three details so I would not forget them. First, I must explicitly add site/.htaccess to the list using its full name, because tar has its own issues with files starting with a dot. Second, the command to delete the archive must be in the same command sequence as extraction, rather than leaving deletion until later, because I will forget. Third, tar does not preserve empty folders; if the project needs empty folders for writing cache or logs, I must run mkdir -p and set their permissions myself after every extraction.
Safe with a full destination path
Suitable for changes to one or two files. The advantage is that it is clear what goes where. The disadvantage is that it is slow, and once it gets slow, people start sending files together into a folder, which leads straight to the basename trap.
Faster, with name collisions prevented
One session handles it all, with complete relative paths and no names collapsed together. But I must watch for files starting with a dot and missing empty folders, and delete the archive in the same command sequence.
Verify After Copying Instead of Trusting the Exit Code
Every symptom in this article has one thing in common: every command reports success. The file transfer tool does its job completely every time. What is wrong is my expectation, not its operation. That leaves only one way to prevent this: always check the actual result after copying.
- Check that the files exist and their sizes are not zero, especially files starting with a dot.
- Request the important URLs and check both the response code and the text inside, rather than checking only for 200.
- For a multilingual site or one with duplicate filenames, compare language-specific markers on each page.
- If the site has a cache or CDN in front of it, clear the cache or append a parameter to the URL before checking; otherwise, I may get old content and reach the wrong conclusion.
- Keep these checks in a script instead of running them manually, because the day people are in a hurry is the day they skip verification.
For me, it comes down to one sentence: it is not the file transfer tool's job to know what I intend. It only knows what I tell it to do. If I write an ambiguous command and it interprets it another way, that does not count as an error on its part. I therefore need to write commands that leave no room for interpretation and check the result every time.
If you currently have a deployment script that sends multiple files into one folder, check whether any filenames are duplicated across folders. It takes two minutes, but if there are duplicates, they are already waiting to overwrite each other. If I have left anything out or made a typo, I will come back and correct it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first