diff --git a/app/api/network.py b/app/api/network.py
index 99c3640de05efc88c707fcd32d45ec6f4b96397c..585bb82d6912f5bfcb543a6fc3fb02d75fbb2f0f 100644
--- a/app/api/network.py
+++ b/app/api/network.py
@@ -203,6 +203,18 @@ def get_hosts():
     return utils.get_generic_model(models.Host, order_by=models.Host.name)
 
 
+@bp.route("/hosts/search")
+@login_required
+def search_hosts():
+    """Search hosts
+
+    .. :quickref: Network; Search hosts
+
+    :query q: the search query
+    """
+    return utils.search_generic_model(models.Host)
+
+
 @bp.route("/hosts", methods=["POST"])
 @login_groups_accepted("admin", "network")
 def create_host():
diff --git a/app/api/utils.py b/app/api/utils.py
index db8c004c1ddac959796ea3d4dd3946be486021cc..b9e69644f3a1fa06d7a063868e9b2a20de14f376 100644
--- a/app/api/utils.py
+++ b/app/api/utils.py
@@ -12,6 +12,7 @@ This module implements useful functions for the API.
 import urllib.parse
 import sqlalchemy as sa
 from flask import current_app, jsonify, request
+from flask_sqlalchemy import Pagination
 from ..extensions import db
 from .. import utils
 
@@ -86,6 +87,26 @@ def get_generic_model(model, order_by=None, query=None):
     return jsonify(data), 200, header
 
 
+def search_generic_model(model):
+    """Return filtered data from model as json
+
+    :param model: model class
+    :returns: filtered data from model as json
+    """
+    kwargs = request.args.to_dict()
+    page = int(kwargs.pop("page", 1))
+    per_page = int(kwargs.pop("per_page", 20))
+    search = kwargs.get("q", "*")
+    instances, nb_filtered = model.search(search, page=page, per_page=per_page)
+    current_app.logger.debug(
+        f'Found {nb_filtered} {model.__tablename__}(s) when searching "{search}"'
+    )
+    data = [instance.to_dict(recursive=True) for instance in instances]
+    pagination = Pagination(None, page, per_page, nb_filtered, None)
+    header = build_pagination_header(pagination, request.base_url, **kwargs)
+    return jsonify(data), 200, header
+
+
 def create_generic_model(model, mandatory_fields=("name",), **kwargs):
     data = request.get_json()
     if data is None:
diff --git a/docs/api.rst b/docs/api.rst
index a489d506184c2945233c1d8e46f81e102f1f6734..0b0a7bc88a5b475e84b09a170dd4b97579b5f5c0 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -137,6 +137,77 @@ If you want to retrieve all items with the status "Loaned".
     ...
     ]
 
+
+Searching
+---------
+
+To search hosts, you can use a :http:get:`/api/v1/network/hosts/search` with your query
+passed using the ``q`` query parameter.
+
+- To search all hosts where the string "archiver" appears in any field, use ``q=archiver``::
+
+    curl -v -H "Authorization: Bearer $TOKEN" $URL/api/v1/network/hosts/search?q=archiver
+
+  Example response::
+
+    < HTTP/1.0 200 OK
+    < Content-Type: application/json
+    < Content-Length: 6336
+    < X-Total-Count: 5
+    < Set-Cookie: session=9afbc88a-5177-4ece-bc55-7840a47290a1; Expires=Mon, 29-Apr-2019 15:27:37 GMT; HttpOnly; Path=/
+    < Server: Werkzeug/0.14.1 Python/3.7.1
+    < Date: Fri, 29 Mar 2019 15:27:37 GMT
+    <
+    [
+      {
+        "ansible_groups": [
+          "aa_cluster_test",
+          "archiver_appliance"
+        ],
+        "created_at": "2018-11-19 10:08",
+        "description": "test archiver",
+        "device_type": "VirtualMachine",
+        "fqdn": "archiver-04.tn.esss.lu.se",
+        "id": 401,
+        ...
+        "name": "archiver-04",
+        "updated_at": "2018-11-20 07:43",
+        "user": "benjaminbertrand"
+      },
+      {
+        ...
+        "name": "archiver-01",
+        "updated_at": "2018-11-28 09:29",
+        "user": "benjaminbertrand"
+        ...
+      },
+      ...
+    ]
+
+
+- To restrict the search to only the *name* field, use ``q=name:archiver``::
+
+    curl -v -H "Authorization: Bearer $TOKEN" $URL/api/v1/network/hosts/search?q=name:archiver
+
+    < HTTP/1.0 200 OK
+    < Content-Type: application/json
+    < Content-Length: 5374
+    < X-Total-Count: 4
+    < Set-Cookie: session=1a637fca-c94b-4ab9-b290-10d18f35453e; Expires=Mon, 29-Apr-2019 15:37:37 GMT; HttpOnly; Path=/
+    < Server: Werkzeug/0.14.1 Python/3.7.1
+    < Date: Fri, 29 Mar 2019 15:37:37 GMT
+    <
+    ...
+
+
+The query string is passed directly to Elasticsearch. You can use exactly the same "mini-language" as when searching
+from the web user interface. Check the elasticsearch `query string syntax`_ for more details as well as the network
+:ref:`network-search` for the list of fields you can use.
+
+Note that if you pass ``q=name:arch``, this will not match *archiver* as elasticsearch uses keywords for string matching.
+In this case you'd need to use a wildcard: ``q=name:arch*``. Be aware that wildcard queries can use an enormous amount
+of memory and perform very badly.
+
 Resources
 ---------
 
@@ -175,3 +246,5 @@ Client
 
 A Python client library is available to access CSEntry API:
 `csentry-api <http://ics-infrastructure.pages.esss.lu.se/csentry-api/index.html>`_
+
+.. _query string syntax: https://www.elastic.co/guide/en/elasticsearch/reference/6.4/query-dsl-query-string-query.html#query-string-syntax
diff --git a/docs/network.rst b/docs/network.rst
index 8fde5890b5c4f7c125d2c5ef1cbba2c13f2f9c08..c317f46168494178625f06a9e5c71bb6ec863a71 100644
--- a/docs/network.rst
+++ b/docs/network.rst
@@ -28,6 +28,8 @@ Stack members should be defined by linking an item to an host from the inventory
 
 .. image:: _static/edit_item_stack_member.png
 
+.. _network-search:
+
 Search
 ------
 
diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py
index a6dfe5fab995f2af7af5c06a6a8ac437608c9ad1..23ca0ed300b9c4cdaa75375e0a41422f67483291 100644
--- a/tests/functional/test_api.py
+++ b/tests/functional/test_api.py
@@ -1673,3 +1673,30 @@ def test_create_cname(client, interface, admin_token):
     check_response_message(
         response, f"Duplicate cname on the {interface.network.domain} domain", 422
     )
+
+
+def test_search_hosts(client, host_factory, readonly_token):
+    # Create some hosts
+    host1 = host_factory(name="test-beautiful", description="The Zen of Python")
+    host_factory(name="test-explicit", description="Beautiful is better than ugly.")
+    host_factory(name="another-host")
+    # When no query is passed, all hosts are returned
+    response = get(client, f"{API_URL}/network/hosts/search", token=readonly_token)
+    assert response.status_code == 200
+    assert len(response.get_json()) == 3
+    # a keyword is searched in all fields by default
+    response = get(
+        client, f"{API_URL}/network/hosts/search?q=beautiful", token=readonly_token
+    )
+    assert response.status_code == 200
+    assert len(response.get_json()) == 2
+    # a search can be restricted to a specific field
+    response = get(
+        client, f"{API_URL}/network/hosts/search?q=name:beautiful", token=readonly_token
+    )
+    assert response.status_code == 200
+    r = response.get_json()
+    assert len(r) == 1
+    assert HOST_KEYS == set(r[0].keys())
+    assert r[0]["name"] == host1.name
+    assert r[0]["description"] == host1.description