All articles
12 September 2026
9 min read

When deployment still runs over FTP

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

A three-pole knife switch on a white wall, its blue handle half thrown.

There is one question I ask in every first conversation about a legacy system, and it is not about the code: how does a change get onto the server?

Answers range from "through the pipeline" to a pause followed by "a colleague uploads it". When the second answer comes, the order of the next few months is settled, whatever else is on the list. Not because FTP is old-fashioned, but because without a reproducible path to the server, every modernization is a risk nobody can put a number on.

This article describes the way from "somebody uploads it" to "one button ships, another one rolls back". It needs no containers, no cloud and no rebuild of the application. That is deliberate: the first step has to be small enough to fit alongside everyday work.

Why the pipeline comes before the code

The reflex is understandable: the code is the problem, so you start with the code. That leads to a state where a team ships modernized code over an unchanged path, and the path is where it goes wrong.

Three things are missing when delivery happens over FTP, and all three are prerequisites for everything that follows.

There is no known state. What sits on the server is the sum of all uploads over the years. Whether it matches what is in the repository, nobody knows. That makes every statement of the form "this change is live" a guess, and every bug report starts with proving something instead of with a cause.

There is no way back. Uploading overwrites. When something goes wrong, the previous version is gone, and the route back runs through a backup or through the developer who still has the file open. Both take time.

There is no moment of switching. An upload takes forty seconds. During those forty seconds the application runs half old and half new, and the errors produced there are the most unpleasant kind of all: they happen once, to one user, and can never be reproduced.

Everything this article describes solves exactly those three problems. Nicer tooling comes later.

Step 1: what is actually on the server?

Before anything gets automated, the difference between repository and server has to be known. It is never zero, and it regularly contains things that are missing from the repository and needed anyway.

# Fetch the server state without touching it
rsync -avn --exclude='.git' --exclude='var/cache' \
  user@server:/var/www/application/ ./server-state/

# Compare against the current main branch
diff -rq ./server-state/ ./repository/ | sort

The output sorts into three groups, and each gets its own answer.

  • Only on the server, needed. Uploaded files, configuration with credentials, a certificate. That must not go into the repository, it has to be kept out of the delivery path. It moves into a directory outside the release and gets linked in.
  • Only on the server, forgotten. A script from 2019, a directory called old, a file ending in .backup. Not deleted, set aside, and in a way that it can still be found in four weeks.
  • On both, but different. This is the important group. Every such file is a change somebody made directly on the server, and it disappears with the first clean deployment. It belongs in the repository before the pipeline exists.

In my experience this single comparison is the step most often skipped and most often responsible for destroying the first automated rollout.

Step 2: build once, ship many times

The second step is a decision, not a technology: what goes to the server is an artefact, and it is produced exactly once.

For a PHP application that means an archive containing the code, the installed dependencies and the compiled front-end files. Nothing gets installed and nothing gets compiled on the server any more.

composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build

echo "$GIT_SHA" > public/version.txt

tar --exclude='.git' --exclude='node_modules' \
    -czf "release-$GIT_SHA.tar.gz" .

The version.txt line looks like a detail and is the most useful one in the whole script. From then on a single request answers which version is running, from the outside and without server access. Every bug report after that starts with a fact.

Why --no-dev on the build machine and not on the target: installing on the server makes deployment depend on third-party package registries being reachable. If one is down, not only the rollout is blocked but the rollback too.

Step 3: the moment of switching

Now comes the part that removes the forty seconds of half-state, and it consists of a directory layout and a symlink.

/var/www/application/
├── releases/
│   ├── 2026-09-16-a3f91c/
│   ├── 2026-09-17-7b2e04/
│   └── 2026-09-18-c81d55/     <- just unpacked
├── shared/
│   ├── uploads/
│   ├── logs/
│   └── .env
└── current -> releases/2026-09-17-7b2e04/

The web server points permanently at current. A deployment unpacks a new directory under releases/, links the shared directories in, and then swings the symlink over. The switch itself is a single atomic operation:

ln -sfn "/var/www/application/releases/$RELEASE" /var/www/application/current.new
mv -Tf /var/www/application/current.new /var/www/application/current

# Otherwise PHP-FPM never learns about the new path: the opcache holds on
# to the old files by their resolved path.
sudo systemctl reload php8.3-fpm

Two traps sit in those four lines, and both have caught me out.

The detour through mv -T is necessary. A direct ln -sfn onto an existing symlink is not atomic; there is a brief moment without a target. On a site under load that is enough for a handful of error pages.

The opcache has to be told. Without the reload, PHP keeps serving from the old directory even though the symlink has long pointed elsewhere. That produces exactly the kind of error nobody can reproduce: the file on disk is new, the behaviour is old.

Step 4: the way back, and rehearsing it

Because the old releases stay in place, the way back is the same command with a different target:

PREVIOUS=$(ls -1dt /var/www/application/releases/*/ | sed -n 2p)
ln -sfn "$PREVIOUS" /var/www/application/current.new
mv -Tf /var/www/application/current.new /var/www/application/current
sudo systemctl reload php8.3-fpm

The important part is not the script but the sentence after it: a way back that has never been taken is not one. Anybody trying it for the first time at 11pm during an outage finds out at that moment that the database migration from earlier does not run backwards.

So the rollback belongs in the first week, not the third: ship once, roll back once, ship forward again. Three minutes, and after that it is a fact rather than an intention.

Switching between two states can be pushed further until releases happen without downtime, and that needs no container platform: Blue/green and canary without Kubernetes.

Step 5: the database is the boundary

The symlink rolls code back. Schema changes it does not. That makes the database the place where this procedure stops working if you are not careful.

The rule against it is simpler than its reputation: every schema change is compatible with the previous version of the code. In practice that means changes break into two steps that sit in two different deployments. The pattern is called expand and contract.

Renaming a column then looks like this:

  1. Deployment 1: create the new column, code writes to both, reads from the old one. The way back stays open, because the previous version simply ignores the new column.
  2. In between: backfill the existing rows, in batches, outside the deployment.
  3. Deployment 2: code reads from the new column, keeps writing to both.
  4. Deployment 3: writing to the old column stops, the column is dropped.

That is three deployments instead of one, and it is exactly why the pipeline is worth the effort: when a rollout is a chore nobody wants to touch, nobody does three of them. When it is a button, they do.

What you do not do in the first week

The most common way to make this fail is to make it bigger. Four things stay deliberately out.

No containers. Moving to containers is a project of its own with its own questions about state, storage and logs. It gets easier once an artefact and a rollout exist, not the other way round.

No new environment. The pipeline ships to exactly where the uploads went before. An additional staging system is the next step and a good one, but it doubles the number of unknowns.

No mandatory tests in the first pass. If there are no tests, a test gate only holds the pipeline up. It goes in as soon as there is something to check; getting tests into a legacy system at all starts with characterization tests.

No rebuild of the configuration. The .env stays where it is for now and gets linked in. Managing it properly is right and can wait.

Anyone planning the step after that will find the route from a directory to an image in A PHP monolith on ECS Fargate.

What is different afterwards

After one or two weeks everyday work looks different, in three measurable places.

The question "which version is running" has an answer, and it sits at /version.txt. That sounds small and it ends a whole class of discussion.

A delivery no longer costs an appointment. Before, a rollout was a scheduled event because somebody had to push files with full attention. Afterwards it is a button, and the number of deliveries per week rises on its own. That is not an end in itself: small deliveries are the most effective remedy against large failures.

The way back is one minute. That changes which changes are defensible at all. Much of what used to be "better not" becomes "let us try, we can go back".

In one project that point was the precondition for everything else: a publishing platform with twenty years of history was rebuilt without a single day of downtime, because every step could be shipped and rolled back on its own. The pipeline there was not the result of the modernization, it was its prerequisite.

If you would rather not walk that path yourself: how I build a deployment pipeline for an existing system is covered on its own page. What is above stays the same either way.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.