Deploying over FTP: from copying files by hand to releases with rollback
An interrupted FTP upload left a mix of old and new code on the server and bookings broke. A build in CI, a one-step switch between releases, and rollback.
- Company
- Travel agency with an online booking site, 20 employees
- Team
- One developer and one web designer
- Stack
- Laravel 11, MySQL, Nginx and PHP-FPM on one VPS
- Constraints
- No staging environment; changes go straight to production
01Starting point
The developer edits code locally and uploads the changed files with an FTP client straight into the directory the site runs from. Then they sign in to the server over SSH and, as needed, run composer install and migrations by hand. Small fixes are sometimes made directly on the server.
The FTP credentials are saved in the FTP client on two computers. FTP sends them across the network unencrypted.
- Developer's computer
- FTP client (unencrypted)
- VPS: the directory the site runs from
- Nginx / PHP-FPM
- MySQL
02The problem
In the middle of the season, the connection dropped while a larger change was being uploaded. The server was left with half the new files and half the old ones. The booking page ended in a fatal PHP error until the developer finished the upload. The Nginx log showed that every booking attempt in that window was affected.
A few days later a second fault surfaced: one file had been forgotten in the upload. The error appeared on only one path through the booking flow, and a customer was the one to find it.
Going back to the previous version was not possible. Nobody knew exactly what was on the server, because some fixes had been made there and were not in Git.
03Technical root cause
Deployment was not atomic. Files were replaced one by one while the site served requests, so during the upload, and after it broke off, a mix of two versions was running.
There was no single built and tested release. The server got whatever the developer selected, without tests and without any check that everything was there.
The server had drifted from the repository, so the repository was not a reliable source of truth and there was nothing to go back to.
Alongside that, a security problem: FTP sends the password unencrypted, and the FTP client keeps it on disk. Whoever obtains it can change the code of the live site.
04Investigation
The server's contents were pulled into a separate branch. git diff showed files edited directly on the server that were missing from Git. They were merged into the main branch so nothing was lost.
The Nginx and PHP-FPM logs showed how long the outage lasted and which paths it hit.
The FTP server configuration confirmed that it did not require an encrypted connection.
05Remediation
Everything is in Git, and the server changes only through a deployment. FTP is off and its password changed.
GitHub Actions on every merge to the main branch: composer install --no-dev --optimize-autoloader, the front end build, php artisan test and packaging of the finished release. If the tests fail, nothing is deployed.
The package reaches the server over SSH, with a key that belongs to CI alone and a dedicated user with rights only to the application directory. Each release is unpacked into its own directory and the site switches to it in a single step.
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/booking
REL="$APP/releases/$(date -u +%Y%m%d%H%M%S)"
mkdir -p "$REL"
tar -xzf /tmp/release.tar.gz -C "$REL" # built and tested in CI
ln -s "$APP/shared/.env" "$REL/.env"
rm -rf "$REL/storage" && ln -s "$APP/shared/storage" "$REL/storage"
cd "$REL"
php artisan migrate --force
php artisan config:cache
php artisan route:cache
# atomic switch: rename(2) replaces the link in one step, ln -sfn does not
ln -s "$REL" "$APP/current.tmp"
mv -T "$APP/current.tmp" "$APP/current"
sudo systemctl reload php8.3-fpm # drops OPcache entries of the old release
curl -fsS --retry 3 https://rezervacie.example.sk/up > /dev/null
# keep the last five releases for rollback
ls -1d "$APP"/releases/* | head -n -5 | xargs -r rm -rfNginx has to resolve the symlink on every request. Otherwise PHP would keep using the old release's paths.
root /srv/booking/current/public;
location ~ \.php$ {
include fastcgi_params;
# resolve the symlink per request, so PHP never mixes two releases
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}Rollback is the same final step with the previous directory, plus a PHP-FPM reload: seconds. The deploying user may run only that one reload through sudo, nothing else.
Migrations are written so that the previous version of the code still works with the new schema: columns are added first and old ones removed only in a later deployment. Otherwise rolling back the code would not help.
06Why the fix works
On the same filesystem, mv -T calls rename, which replaces the current link in one step. Every request sees either the whole old release or the whole new one, never a mix. ln -sfn would delete the link and then create a new one, leaving a brief moment without it.
$realpath_root in Nginx and the PHP-FPM reload make sure that after the switch PHP reads the new release's files and OPcache does not hold on to the old paths.
The release is built and tested in CI, so what reaches the server is always a whole that passed the tests, not a selection of files.
Old releases stay on disk, so rollback builds and downloads nothing. It just switches the link.
An SSH key belonging to CI alone replaces an FTP password that travelled the network unencrypted.
What it does not address: switching the link does not roll back a database migration. That is why migrations are written to be backward compatible.
07Validation
- A deliberately broken test stopped the deployment in CI.
- A loop of requests to the booking page ran during a deployment, and none returned an error.
- Rollback to the previous release was tried for real and written into the procedure.
- The FTP port no longer answers on the server.
08Results
Fixed
- A deployment can no longer leave a mix of two versions on the server.
- Nothing reaches the server without passing the tests.
- The FTP password no longer crosses the network; FTP is off.
Risk reduced
- Rolling back to the previous release takes seconds.
- The server matches the repository, so we always know what is running.
Still open
- There is no staging environment; changes first meet real data in production.
- Migrations can only be reversed by hand.
09Limitations
- The SSH key in CI is a powerful permission. Whoever controls the repository or its secrets can deploy anything.
- Tests are only as good as their coverage. Part of the booking flow still has none.
- There is one server. Deploying without downtime does not mean running without downtime.
10Lessons
- A deployment should be one atomic step, not a series of copies.
- The repository is the single source of truth. Whatever is changed directly on the server disappears with the next deployment.
- Try the rollback before you need it.
- Write migrations so the previous version of the code still works with them.
- FTP with a password belongs to the past. SSH with a single-purpose key is both safer and easier.
11Next steps for a small company
- 1.A staging environment on the same server with its own database.
- 2.An automatic database backup before every migration.
- 3.Require a pull request and passing tests on GitHub before merging to the main branch.
- 4.Add tests covering the whole booking flow.
Recognise your own company in this?
Tell us what it involves. We reply within one business day and say whether it is work for us, even when the answer is no.