diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst
index 332a14eb7..1e0e0f18f 100644
--- a/docs/topics/media-pipeline.rst
+++ b/docs/topics/media-pipeline.rst
@@ -147,6 +147,25 @@ Where:
 * ``full`` is a sub-directory to separate full images from thumbnails (if
   used). For more info see :ref:`topics-images-thumbnails`.
 
+FTP server storage
+------------------
+
+:setting:`FILES_STORE` and :setting:`IMAGES_STORE` can point to an FTP server.
+Scrapy will automatically upload the files to the server.
+
+:setting:`FILES_STORE` and :setting:`IMAGES_STORE` should be written in one of the
+following forms::
+
+    ftp://username:password@address:port/path
+    ftp://address:port/path
+    
+If ``username`` and ``password`` are not provided, they are taken from the :setting:`FTP_USER` and
+:setting:`FTP_PASSWORD` settings respectively.
+
+FTP supports two different connection modes: active or passive. Scrapy uses
+the passive connection mode by default. To use the active connection mode instead,
+set the :setting:`FEED_STORAGE_FTP_ACTIVE` setting to ``True``.
+
 Amazon S3 storage
 -----------------
 
diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py
index 11a63ced2..f1b101780 100644
--- a/scrapy/extensions/feedexport.py
+++ b/scrapy/extensions/feedexport.py
@@ -7,18 +7,16 @@ See documentation in docs/topics/feed-exports.rst
 import os
 import sys
 import logging
-import posixpath
 from tempfile import NamedTemporaryFile
 from datetime import datetime
 from urllib.parse import urlparse, unquote
-from ftplib import FTP
 
 from zope.interface import Interface, implementer
 from twisted.internet import defer, threads
 from w3lib.url import file_uri_to_path
 
 from scrapy import signals
-from scrapy.utils.ftp import ftp_makedirs_cwd
+from scrapy.utils.ftp import ftp_store_file
 from scrapy.exceptions import NotConfigured
 from scrapy.utils.misc import create_instance, load_object
 from scrapy.utils.log import failure_to_exc_info
@@ -173,16 +171,11 @@ class FTPFeedStorage(BlockingFeedStorage):
         )
 
     def _store_in_thread(self, file):
-        file.seek(0)
-        ftp = FTP()
-        ftp.connect(self.host, self.port)
-        ftp.login(self.username, self.password)
-        if self.use_active_mode:
-            ftp.set_pasv(False)
-        dirname, filename = posixpath.split(self.path)
-        ftp_makedirs_cwd(ftp, dirname)
-        ftp.storbinary('STOR %s' % filename, file)
-        ftp.quit()
+        ftp_store_file(
+            path=self.path, file=file, host=self.host,
+            port=self.port, username=self.username,
+            password=self.password, use_active_mode=self.use_active_mode
+        )
 
 
 class SpiderSlot(object):
diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py
index 6d55c8980..9b7445755 100644
--- a/scrapy/pipelines/files.py
+++ b/scrapy/pipelines/files.py
@@ -11,6 +11,7 @@ import os
 import time
 from collections import defaultdict
 from email.utils import parsedate_tz, mktime_tz
+from ftplib import FTP
 from io import BytesIO
 from urllib.parse import urlparse
 
@@ -26,6 +27,7 @@ from scrapy.utils.python import to_bytes
 from scrapy.utils.request import referer_str
 from scrapy.utils.boto import is_botocore
 from scrapy.utils.datatypes import CaselessDict
+from scrapy.utils.ftp import ftp_store_file
 
 
 logger = logging.getLogger(__name__)
@@ -257,6 +259,49 @@ class GCSFilesStore(object):
         )
 
 
+class FTPFilesStore(object):
+
+    FTP_USERNAME = None
+    FTP_PASSWORD = None
+    USE_ACTIVE_MODE = None
+
+    def __init__(self, uri):
+        assert uri.startswith('ftp://')
+        u = urlparse(uri)
+        self.port = u.port
+        self.host = u.hostname
+        self.port = int(u.port or 21)
+        self.username = u.username or self.FTP_USERNAME
+        self.password = u.password or self.FTP_PASSWORD
+        self.basedir = u.path.rstrip('/')
+
+    def persist_file(self, path, buf, info, meta=None, headers=None):
+        path = '%s/%s' % (self.basedir, path)
+        return threads.deferToThread(
+            ftp_store_file, path=path, file=buf,
+            host=self.host, port=self.port, username=self.username,
+            password=self.password, use_active_mode=self.USE_ACTIVE_MODE
+        )
+
+    def stat_file(self, path, info):
+        def _stat_file(path):
+            try:
+                ftp = FTP()
+                ftp.connect(self.host, self.port)
+                ftp.login(self.username, self.password)
+                if self.USE_ACTIVE_MODE:
+                    ftp.set_pasv(False)
+                file_path = "%s/%s" % (self.basedir, path)
+                last_modified = float(ftp.voidcmd("MDTM %s" % file_path)[4:].strip())
+                m = hashlib.md5()
+                ftp.retrbinary('RETR %s' % file_path, m.update)
+                return {'last_modified': last_modified, 'checksum': m.hexdigest()}
+            # The file doesn't exist
+            except Exception:
+                return {}
+        return threads.deferToThread(_stat_file, path)
+
+
 class FilesPipeline(MediaPipeline):
     """Abstract pipeline that implement the file downloading
 
@@ -283,6 +328,7 @@ class FilesPipeline(MediaPipeline):
         'file': FSFilesStore,
         's3': S3FilesStore,
         'gs': GCSFilesStore,
+        'ftp': FTPFilesStore
     }
     DEFAULT_FILES_URLS_FIELD = 'file_urls'
     DEFAULT_FILES_RESULT_FIELD = 'files'
@@ -330,6 +376,11 @@ class FilesPipeline(MediaPipeline):
         gcs_store.GCS_PROJECT_ID = settings['GCS_PROJECT_ID']
         gcs_store.POLICY = settings['FILES_STORE_GCS_ACL'] or None
 
+        ftp_store = cls.STORE_SCHEMES['ftp']
+        ftp_store.FTP_USERNAME = settings['FTP_USER']
+        ftp_store.FTP_PASSWORD = settings['FTP_PASSWORD']
+        ftp_store.USE_ACTIVE_MODE = settings.getbool('FEED_STORAGE_FTP_ACTIVE')
+
         store_uri = settings['FILES_STORE']
         return cls(store_uri, settings=settings)
 
diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py
index e9c6b759c..2e646379c 100644
--- a/scrapy/pipelines/images.py
+++ b/scrapy/pipelines/images.py
@@ -94,6 +94,11 @@ class ImagesPipeline(FilesPipeline):
         gcs_store.GCS_PROJECT_ID = settings['GCS_PROJECT_ID']
         gcs_store.POLICY = settings['IMAGES_STORE_GCS_ACL'] or None
 
+        ftp_store = cls.STORE_SCHEMES['ftp']
+        ftp_store.FTP_USERNAME = settings['FTP_USER']
+        ftp_store.FTP_PASSWORD = settings['FTP_PASSWORD']
+        ftp_store.USE_ACTIVE_MODE = settings.getbool('FEED_STORAGE_FTP_ACTIVE')
+
         store_uri = settings['IMAGES_STORE']
         return cls(store_uri, settings=settings)
 
diff --git a/scrapy/utils/ftp.py b/scrapy/utils/ftp.py
index 91d2439a9..f07bdd748 100644
--- a/scrapy/utils/ftp.py
+++ b/scrapy/utils/ftp.py
@@ -1,4 +1,6 @@
-from ftplib import error_perm
+import posixpath
+
+from ftplib import error_perm, FTP
 from posixpath import dirname
 
 
@@ -14,3 +16,20 @@ def ftp_makedirs_cwd(ftp, path, first_call=True):
         ftp.mkd(path)
         if first_call:
             ftp.cwd(path)
+
+
+def ftp_store_file(
+        *, path, file, host, port,
+        username, password, use_active_mode=False):
+    """Opens a FTP connection with passed credentials,sets current directory
+    to the directory extracted from given path, then uploads the file to server
+    """
+    with FTP() as ftp:
+        ftp.connect(host, port)
+        ftp.login(username, password)
+        if use_active_mode:
+            ftp.set_pasv(False)
+        file.seek(0)
+        dirname, filename = posixpath.split(path)
+        ftp_makedirs_cwd(ftp, dirname)
+        ftp.storbinary('STOR %s' % filename, file)

Save images on remote server
I want to save images on a remote server via (FTP?). Is this possible with scrapy? 
(I know about S3 or Google but i mean in a custom FTP server).
diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py
index 0f4cf8091..faac0b12f 100644
--- a/scrapy/utils/test.py
+++ b/scrapy/utils/test.py
@@ -1,6 +1,9 @@
 """
 This module contains some assorted functions used in tests
 """
+
+from __future__ import absolute_import
+from posixpath import split
 import asyncio
 import os
 
@@ -63,6 +66,26 @@ def get_gcs_content_and_delete(bucket, path):
     return content, acl, blob
 
 
+def get_ftp_content_and_delete(
+        path, host, port, username,
+        password, use_active_mode=False):
+    from ftplib import FTP
+    ftp = FTP()
+    ftp.connect(host, port)
+    ftp.login(username, password)
+    if use_active_mode:
+        ftp.set_pasv(False)
+    ftp_data = []
+
+    def buffer_data(data):
+        ftp_data.append(data)
+    ftp.retrbinary('RETR %s' % path, buffer_data)
+    dirname, filename = split(path)
+    ftp.cwd(dirname)
+    ftp.delete(filename)
+    return "".join(ftp_data)
+
+
 def get_crawler(spidercls=None, settings_dict=None):
     """Return an unconfigured Crawler object. If settings_dict is given, it
     will be used to populate the crawler settings with a project level
diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py
index 141141671..e5bad2ed0 100644
--- a/tests/test_pipeline_files.py
+++ b/tests/test_pipeline_files.py
@@ -10,12 +10,13 @@ from urllib.parse import urlparse
 from twisted.trial import unittest
 from twisted.internet import defer
 
-from scrapy.pipelines.files import FilesPipeline, FSFilesStore, S3FilesStore, GCSFilesStore
+from scrapy.pipelines.files import FilesPipeline, FSFilesStore, S3FilesStore, GCSFilesStore, FTPFilesStore
 from scrapy.item import Item, Field
 from scrapy.http import Request, Response
 from scrapy.settings import Settings
 from scrapy.utils.test import assert_aws_environ, get_s3_content_and_delete
 from scrapy.utils.test import assert_gcs_environ, get_gcs_content_and_delete
+from scrapy.utils.test import get_ftp_content_and_delete
 from scrapy.utils.boto import is_botocore
 
 
@@ -367,6 +368,31 @@ class TestGCSFilesStore(unittest.TestCase):
         self.assertIn(expected_policy, acl)
 
 
+class TestFTPFileStore(unittest.TestCase):
+    @defer.inlineCallbacks
+    def test_persist(self):
+        uri = os.environ.get('FTP_TEST_FILE_URI')
+        if not uri:
+            raise unittest.SkipTest("No FTP URI available for testing")
+        data = b"TestFTPFilesStore: \xe2\x98\x83"
+        buf = BytesIO(data)
+        meta = {'foo': 'bar'}
+        path = 'full/filename'
+        store = FTPFilesStore(uri)
+        empty_dict = yield store.stat_file(path, info=None)
+        self.assertEqual(empty_dict, {})
+        yield store.persist_file(path, buf, info=None, meta=meta, headers=None)
+        stat = yield store.stat_file(path, info=None)
+        self.assertIn('last_modified', stat)
+        self.assertIn('checksum', stat)
+        self.assertEqual(stat['checksum'], 'd113d66b2ec7258724a268bd88eef6b6')
+        path = '%s/%s' % (store.basedir, path)
+        content = get_ftp_content_and_delete(
+            path, store.host, store.port,
+            store.username, store.password, store.USE_ACTIVE_MODE)
+        self.assertEqual(data.decode(), content)
+
+
 class ItemWithFiles(Item):
     file_urls = Field()
     files = Field()

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
apt-get update apt-get install -y gcc
3.9
tests/requirements-py3.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
asgiref==3.5.2
asttokens==3.0.0
attrs==25.3.0
Automat==24.8.1
blessed==1.20.0
bpython==0.25
Brotli==1.0.9
brotlipy==0.7.0
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
constantly==23.10.4
coverage==7.8.0
cryptography==38.0.4
cssselect==1.3.0
curtsies==0.4.2
cwcwidth==0.1.10
decorator==5.2.1
exceptiongroup==1.2.2
execnet==2.1.1
executing==2.2.0
Flask==2.2.5
greenlet==3.1.1
h11==0.14.0
h2==4.2.0
hpack==4.1.0
hyperframe==6.1.0
hyperlink==21.0.0
idna==3.10
importlib_metadata==8.6.1
incremental==24.7.2
iniconfig==2.1.0
ipython==8.18.1
itsdangerous==2.2.0
jedi==0.19.2
Jinja2==3.1.6
jmespath==1.0.1
kaitaistruct==0.10
ldap3==2.9.1
lxml==5.3.1
MarkupSafe==3.0.2
matplotlib-inline==0.1.7
mitmproxy==9.0.1
mitmproxy_wireguard==0.1.23
msgpack==1.0.8
packaging==24.2
parsel==1.10.0
parso==0.8.4
passlib==1.7.4
pexpect==4.9.0
pluggy==1.5.0
prompt_toolkit==3.0.50
Protego==0.4.0
protobuf==4.25.6
ptyprocess==0.7.0
publicsuffix2==2.20191221
pure_eval==0.2.3
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycparser==2.22
PyDispatcher==2.0.7
Pygments==2.19.1
pyOpenSSL==22.1.0
pyparsing==3.0.9
pyperclip==1.8.2
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-twisted==1.14.3
pytest-xdist==3.6.1
pyxdg==0.28
queuelib==1.7.0
requests==2.32.3
ruamel.yaml==0.17.40
ruamel.yaml.clib==0.2.12
-e git+https://github.com/scrapy/scrapy.git@bd54f22fefcee34aecf585a558dc4842b7f60a2c#egg=Scrapy
service-identity==24.2.0
six==1.17.0
sortedcontainers==2.4.0
stack-data==0.6.3
sybil==9.1.0
testfixtures==8.3.0
tomli==2.2.1
tornado==6.4.2
traitlets==5.14.3
Twisted==24.11.0
typing_extensions==4.13.0
urllib3==2.3.0
urwid==2.1.2
w3lib==2.3.1
wcwidth==0.2.13
Werkzeug==3.1.3
wsproto==1.2.0
zipp==3.21.0
zope.interface==7.2
zstandard==0.19.0

name: scrapy
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - asgiref==3.5.2
      - asttokens==3.0.0
      - attrs==25.3.0
      - automat==24.8.1
      - blessed==1.20.0
      - bpython==0.25
      - brotli==1.0.9
      - brotlipy==0.7.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - constantly==23.10.4
      - coverage==7.8.0
      - cryptography==38.0.4
      - cssselect==1.3.0
      - curtsies==0.4.2
      - cwcwidth==0.1.10
      - decorator==5.2.1
      - exceptiongroup==1.2.2
      - execnet==2.1.1
      - executing==2.2.0
      - flask==2.2.5
      - greenlet==3.1.1
      - h11==0.14.0
      - h2==4.2.0
      - hpack==4.1.0
      - hyperframe==6.1.0
      - hyperlink==21.0.0
      - idna==3.10
      - importlib-metadata==8.6.1
      - incremental==24.7.2
      - iniconfig==2.1.0
      - ipython==8.18.1
      - itsdangerous==2.2.0
      - jedi==0.19.2
      - jinja2==3.1.6
      - jmespath==1.0.1
      - kaitaistruct==0.10
      - ldap3==2.9.1
      - lxml==5.3.1
      - markupsafe==3.0.2
      - matplotlib-inline==0.1.7
      - mitmproxy==9.0.1
      - mitmproxy-wireguard==0.1.23
      - msgpack==1.0.8
      - packaging==24.2
      - parsel==1.10.0
      - parso==0.8.4
      - passlib==1.7.4
      - pexpect==4.9.0
      - pluggy==1.5.0
      - prompt-toolkit==3.0.50
      - protego==0.4.0
      - protobuf==4.25.6
      - ptyprocess==0.7.0
      - publicsuffix2==2.20191221
      - pure-eval==0.2.3
      - pyasn1==0.6.1
      - pyasn1-modules==0.4.2
      - pycparser==2.22
      - pydispatcher==2.0.7
      - pygments==2.19.1
      - pyopenssl==22.1.0
      - pyparsing==3.0.9
      - pyperclip==1.8.2
      - pytest==8.3.5
      - pytest-asyncio==0.26.0
      - pytest-cov==6.0.0
      - pytest-mock==3.14.0
      - pytest-twisted==1.14.3
      - pytest-xdist==3.6.1
      - pyxdg==0.28
      - queuelib==1.7.0
      - requests==2.32.3
      - ruamel-yaml==0.17.40
      - ruamel-yaml-clib==0.2.12
      - service-identity==24.2.0
      - six==1.17.0
      - sortedcontainers==2.4.0
      - stack-data==0.6.3
      - sybil==9.1.0
      - testfixtures==8.3.0
      - tomli==2.2.1
      - tornado==6.4.2
      - traitlets==5.14.3
      - twisted==24.11.0
      - typing-extensions==4.13.0
      - urllib3==2.3.0
      - urwid==2.1.2
      - w3lib==2.3.1
      - wcwidth==0.2.13
      - werkzeug==3.1.3
      - wsproto==1.2.0
      - zipp==3.21.0
      - zope-interface==7.2
      - zstandard==0.19.0
prefix: /opt/conda/envs/scrapy

tests/test_pipeline_files.py::FilesPipelineTestCase::test_file_expired tests/test_pipeline_files.py::FilesPipelineTestCase::test_file_not_expired tests/test_pipeline_files.py::FilesPipelineTestCase::test_file_path tests/test_pipeline_files.py::FilesPipelineTestCase::test_fs_store tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_cls_attrs_with_DEFAULT_prefix tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_custom_settings_and_class_attrs_for_subclasses tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_custom_settings_for_subclasses tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_different_settings_for_different_instances tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_no_custom_settings_for_subclasses tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_subclass_attributes_preserved_if_no_settings tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_subclass_attrs_preserved_custom_settings tests/test_pipeline_files.py::FilesPipelineTestCaseCustomSettings::test_user_defined_subclass_default_key_names tests/test_pipeline_files.py::TestS3FilesStore::test_persist
tests/test_pipeline_files.py::FilesPipelineTestCaseFields::test_item_fields_default tests/test_pipeline_files.py::FilesPipelineTestCaseFields::test_item_fields_override_settings
scrapy/pipelines/images.py docs/topics/media-pipeline.rst scrapy/utils/ftp.py scrapy/extensions/feedexport.py scrapy/pipelines/files.py
scrapy/pipelines/images.py docs/topics/media-pipeline.rst scrapy/utils/ftp.py scrapy/extensions/feedexport.py scrapy/pipelines/files.py