Skip to main content
CakeDC Blog

Testing SFTP Integrations Locally with DDEV and CakePHP

Sooner or later a CakePHP application has to talk to somebody else's SFTP server: a bank that drops a settlement file every night, a logistics partner that expects a CSV in a chrooted upload/ folder, a legacy ERP that only speaks SSH file transfer. The integration itself is rarely the hard part. The hard part is testing it without a real server.

The usual options are all bad. You can point local dev at the partner's staging SFTP — if they even have one — and pray you never upload garbage to a box you don't own. You can share one set of credentials across the whole team and rotate them by hand. Or you can mock the transport entirely and find out in production that phpseclib returns something your mock never did.

This article walks through a fourth option: run a real SFTP server as a local DDEV service, wired into a CakePHP 5 app, so the transfer path you exercise on your laptop is the same one that runs in production. No shared credentials, no remote box, no mocks. The full demo lives in this repository under .ddev/ and src/Service/Sftp/.

What we are building

The moving parts are small:

  • A throwaway SFTP server (atmoz/sftp) running next to the web container, reachable from PHP as the host sftp on port 22.
  • SSH key authentication only — no passwords, the key pair is committed as a dev-only credential.
  • A tiny SftpClient service in CakePHP that uploads files with a safe staging strategy, lists the remote directory, and cleans it up.
  • A one-page web demo at /sftp-demo to upload dummy files and watch them land.

Here is the shape of it end to end:

Browser  ──POST /sftp-demo/upload──▶  CakePHP web container
                                          │
                        DummyFileGeneratorService writes N files to TMP
                                          │
                        SftpClient (phpseclib3)  ──SSH key──▶  ddev-<site>-sftp
                                                                   │
                                                          /home/feeduser/upload

Step 1 — Add the SFTP service to DDEV

DDEV picks up any docker-compose.*.yaml file in .ddev/ and merges it into the project. That is the whole extension mechanism — drop a compose file in, run ddev restart, and you have a new service on the project network.

.ddev/docker-compose.sftp.yaml:

# Local SFTP server to simulate an external partner upload dropbox.
# Reachable from the web container as host "sftp" (port 22).
# Auth: SSH key only (dev public key under .ddev/sftp/test_key.pub).
# Writable upload dir for the chrooted user: /upload
services:
  sftp:
    container_name: ddev-${DDEV_SITENAME}-sftp
    image: atmoz/sftp:alpine
    # These labels make the service discoverable/managed by ddev.
    labels:
      com.ddev.site-name: ${DDEV_SITENAME}
      com.ddev.approot: $DDEV_APPROOT
    volumes:
      - "./sftp/test_key.pub:/home/feeduser/.ssh/keys/test_key.pub:ro"
    # user:pass:e:uid:gid:dir -> no password (key only), uid/gid 1001, writable "upload" dir
    command: ["feeduser::1001:1001:upload"]

Two details matter here.

The com.ddev.* labels are what turn a plain container into a DDEV-managed service — without them DDEV won't own the container's lifecycle. The service name sftp becomes a resolvable hostname on the Docker network, which is why the app can connect to sftp:22 with no IP juggling.

The command is atmoz/sftp's user spec: user:pass:e:uid:gid:dir. We leave the password empty so the account is key-only, pin uid/gid to 1001, and ask for a writable upload directory inside the user's chrooted home. That mirrors the way most partner dropboxes are set up: you land in your own jail and you can only write where they let you.

Step 2 — Generate a dev-only key pair

The server trusts one public key, mounted read-only into the container. Generate the pair once and commit it — this key guards nothing but a disposable local container, so it is fine in the repo. Never reuse a production key here.

ssh-keygen -t ed25519 -N '' -f .ddev/sftp/test_key

That gives you .ddev/sftp/test_key (private) and .ddev/sftp/test_key.pub (public). The compose file above already mounts the .pub into /home/feeduser/.ssh/keys/, where atmoz/sftp picks it up automatically.

Step 3 — Install an SFTP client for PHP

We use phpseclib, a pure-PHP SSH/SFTP implementation, so there is no dependency on the ssh2 PHP extension being present in every environment.

ddev composer require phpseclib/phpseclib:^3.0

Step 4 — Wire configuration through the environment

Connection details come from environment variables so the same code runs locally against the DDEV service and in production against the real host. In DDEV, set them under web_environment in .ddev/config.yaml:

web_environment:
    - SFTP_DEMO_HOST=sftp
    - SFTP_DEMO_PORT=22
    - SFTP_DEMO_USERNAME=feeduser
    - SFTP_DEMO_PRIVATE_KEY=/var/www/html/.ddev/sftp/test_key
    - SFTP_DEMO_REMOTE_DIR=upload
    - SFTP_DEMO_HOST_FINGERPRINT=

Then read them into CakePHP's Configure with a small config file, config/sftp_demo.php:

<?php
declare(strict_types=1);

return [
    'SftpDemo' => [
        'host' => env('SFTP_DEMO_HOST', 'sftp'),
        'port' => (int)env('SFTP_DEMO_PORT', 22),
        'username' => env('SFTP_DEMO_USERNAME', 'feeduser'),
        // Path to the private key file, or the key contents themselves.
        'private_key' => env('SFTP_DEMO_PRIVATE_KEY', ''),
        'private_key_passphrase' => env('SFTP_DEMO_PRIVATE_KEY_PASSPHRASE', ''),
        // Optional expected server host key fingerprint (OpenSSH SHA256).
        // When set, the connection is rejected if it does not match (anti-MITM).
        // Leave empty in local dev.
        'host_fingerprint' => env('SFTP_DEMO_HOST_FINGERPRINT', ''),
        'remote_dir' => env('SFTP_DEMO_REMOTE_DIR', 'upload'),
    ],
];

Load it once from config/bootstrap.php:

Configure::load('sftp_demo', 'default');

Notice SFTP_DEMO_HOST_FINGERPRINT is empty locally. In production you fill it with the server's SHA256 host key fingerprint and the client refuses to connect if the presented key doesn't match — the same trust-on-first-use protection ssh gives you on the command line. We'll come back to it.

Step 5 — A small SFTP client service

All the transfer logic lives in one service, src/Service/Sftp/SftpClient.php. The most interesting method is upload(), because a naive upload has a subtle bug: if you write files under their final names and the batch fails halfway, the partner's poller can pick up a half-finished set. The fix is to stage every file under a tmp- name and only rename to the final name once all uploads succeeded.

public function upload(array $localPaths, string $remoteDir): array
{
    $sftp = $this->connect();
    $staged = [];
    $remotePaths = [];

    try {
        $remoteDir = rtrim($remoteDir, '/');
        if ($remoteDir !== '' && !$sftp->is_dir($remoteDir) && !$sftp->mkdir($remoteDir, -1, true)) {
            throw new RuntimeException(sprintf('Could not create remote directory "%s"', $remoteDir));
        }
        $prefix = $remoteDir !== '' ? $remoteDir . '/' : '';

        foreach ($localPaths as $localPath) {
            $final = $prefix . basename($localPath);
            $temp = $prefix . 'tmp-' . basename($localPath);

            if (!$sftp->put($temp, $localPath, SFTP::SOURCE_LOCAL_FILE)) {
                throw new RuntimeException(sprintf('Failed to upload "%s"', $localPath));
            }
            $staged[$temp] = $final;
        }

        // Promote staged files to their final names only after all succeeded.
        foreach ($staged as $temp => $final) {
            if ($sftp->is_file($final)) {
                $sftp->delete($final);
            }
            if (!$sftp->rename($temp, $final)) {
                throw new RuntimeException(sprintf('Failed to promote "%s"', $temp));
            }
            $remotePaths[] = $final;
        }
    } catch (\Throwable $e) {
        // Best-effort cleanup of leftover temp files; final names stay untouched.
        foreach (array_keys($staged) as $temp) {
            if ($sftp->is_file($temp)) {
                $sftp->delete($temp);
            }
        }
        throw $e;
    } finally {
        $sftp->disconnect();
    }

    return $remotePaths;
}

Connecting is where the host key check happens. In local dev the fingerprint is empty and the check is skipped; in production it pins the server identity:

private function connect(): SFTP
{
    $host = (string)($this->config['host'] ?? '');
    $username = (string)($this->config['username'] ?? '');
    if ($host === '' || $username === '') {
        throw new RuntimeException('SFTP requires SftpDemo.host and SftpDemo.username');
    }

    $sftp = new SFTP($host, (int)($this->config['port'] ?? 22));
    $this->verifyHostKey($sftp, $host); // no-op when no fingerprint configured

    if (!$sftp->login($username, $this->loadPrivateKey())) {
        throw new RuntimeException(sprintf('SFTP login failed for "%s@%s"', $username, $host));
    }

    return $sftp;
}

Step 6 — Expose it in a controller

The controller is thin: generate some dummy files, hand them to the client, report what landed. All query/transfer logic stays in the service.

public function upload()
{
    $this->request->allowMethod('post');

    $config = (array)Configure::read('SftpDemo');
    $remoteDir = (string)($config['remote_dir'] ?? 'upload');
    $count = (int)$this->request->getData('count', 3);

    try {
        $localPaths = (new DummyFileGeneratorService())
            ->generate($count, TMP . 'sftp_demo');
        $remotePaths = (new SftpClient($config))->upload($localPaths, $remoteDir);

        $this->Flash->success(sprintf('Uploaded %d file(s): %s', count($remotePaths), implode(', ', $remotePaths)));
    } catch (\Throwable $e) {
        $this->Flash->error('SFTP upload failed: ' . $e->getMessage());
    }

    return $this->redirect(['action' => 'index']);
}

Wire the routes in config/routes.php:

$builder->connect('/sftp-demo', ['controller' => 'SftpDemo', 'action' => 'index']);
$builder->connect('/sftp-demo/upload', ['controller' => 'SftpDemo', 'action' => 'upload']);
$builder->connect('/sftp-demo/clear', ['controller' => 'SftpDemo', 'action' => 'clear']);

Restart DDEV and visit https://<your-site>.ddev.site/sftp-demo. Upload a batch, and the same page lists the remote directory with file names and sizes — read straight back off the SFTP server, not from a local cache.

A gotcha worth knowing: phpseclib's rawlist()

The listing code first looked like this, copied from an example that assumed object attributes:

if ($attrs->type === 2) { // directory
    continue;
}
$files[] = ['name' => $name, 'size' => $attrs->size];

Every file came back with size 0, and directories were never skipped. The reason: phpseclib 3 returns associative arrays from rawlist(), not objects. $attrs->size on an array is null, cast to 0. The fix is to normalize both shapes so the code survives a future change back to objects:

// rawlist() returns associative arrays in phpseclib 3; normalize array/object access.
$type = is_array($attrs) ? ($attrs['type'] ?? null) : ($attrs->type ?? null);
$size = is_array($attrs) ? ($attrs['size'] ?? 0) : ($attrs->size ?? 0);
if ($type === 2) { // type 2 == directory; skip dirs, keep regular files
    continue;
}
$files[] = ['name' => (string)$name, 'size' => (int)$size];

This is exactly the class of bug a mock would have hidden. Against a real server it showed up on the first listing.

Cleaning up between runs

Because it's a real server, files persist across page loads. Two easy ways to reset. From the shell:

printf 'cd /upload\nrm *\n' > tmp/sftp_clean
ddev exec 'sftp -i .ddev/sftp/test_key -o StrictHostKeyChecking=no -b tmp/sftp_clean feeduser@sftp'

Or from the demo page itself, which ships a "clear remote" button backed by a POST-only clear() action and SftpClient::deleteRemote() — a Form->postLink carries the CSRF token and asks for confirmation before wiping the directory.

From local to production

The jump to a real environment is a configuration change, not a code change. Point the same environment variables at the partner's host and user, supply the real private key (path or contents), and — this is the important one — set SFTP_DEMO_HOST_FINGERPRINT to the server's SHA256 host key. With the fingerprint set, verifyHostKey() rejects any server whose key doesn't match, closing the man-in-the-middle window that an unpinned connection leaves open.

You get the same SftpClient, exercised the same way, from your laptop to production. The only thing that changed between them is six environment variables.

Closing thoughts

Running dependencies as local services instead of mocking them is a small habit with a large payoff. A DDEV compose file and a throwaway key pair bought us a real SFTP server that every developer gets for free on ddev restart, no shared credentials and nothing to clean up on a box we don't own. It also surfaced a real phpseclib quirk before it could reach production — which is the whole point. The closer local dev sits to the real transport, the fewer surprises survive to deploy day.

Need help with this in your own CakePHP app?

Talk to a CakePHP expert
Back to all articles
We Bake with CakePHP