diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..1b823a2c19a3eabf1719d24b74771549a44a232e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+dist
+build
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 07d3af5871d86928bd7f7024483f56113115e2d7..4be1cfe048bf1ee528006997c67698300fd076eb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,6 @@
 stages:
   - test
+  - build
 
 variables:
   PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
@@ -17,3 +18,47 @@ test:
     - pytest
   tags:
     - docker
+
+build-linux:
+  stage: build
+  image: python:3.6
+  before_script:
+    - pip install .[dev]
+  script:
+    - pyinstaller --clean -y cli.spec --distpath .
+    - echo "Testing the generated CLI works…" && ./funkwhale --help && echo "funkwhale CLI working \o/"
+  artifacts:
+    name: "linux_${CI_COMMIT_REF_NAME}"
+    paths:
+      - funkwhale
+  only:
+    - tags@funkwhale/cli
+    - master@funkwhale/cli
+
+  tags:
+    - docker
+
+build-windows:
+  # there is a weird Gitlab / windows interaction
+  # cf https://github.com/cdrx/docker-pyinstaller/issues/38
+  # so we cannot use the regular docker executor
+  stage: build
+  image: docker:stable
+  tags:
+    - docker-build
+
+  variables:
+    # CI_DEBUG_TRACE: "true"
+  script:
+    - docker run --rm -v "$(pwd):/src/" cdrx/pyinstaller-windows:python3 "pip install -r requirements-dev.txt && pyinstaller --clean -y cli.spec --distpath ."
+    - docker run --rm -v "$(pwd):/src/" cdrx/pyinstaller-windows:python3 "echo 'Testing the generated CLI works…' && wine ./funkwhale.exe --help && echo 'funkwhale CLI working \o/'"
+  artifacts:
+    name: "linux_${CI_COMMIT_REF_NAME}"
+    paths:
+      - funkwhale.exe
+  only:
+    - tags@funkwhale/cli
+    - master@funkwhale/cli
+
+  tags:
+    - docker-build
diff --git a/README.md b/README.md
index dddb1640a3ffdbff6318fd38b18bfb9b7cf6327c..ff6fb206aeb0a626b9a1216aa516a4e5de499e3b 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,39 @@ A command line interface to interact with Funkwhale servers.
 
 # Installation
 
+We provide some prebuilt binaries for Windows and Linux.
+
+On Linux:
+
+```
+curl -L "https://dev.funkwhale.audio/funkwhale/cli/-/jobs/artifacts/master/raw/funkwhale?job=build-linux" -o /usr/local/bin/funkwhale
+chmod +x /usr/local/bin/funkwhale
+```
+
+On Windows:
+
+```
+curl -L "https://dev.funkwhale.audio/funkwhale/cli/-/jobs/artifacts/master/raw/funkwhale.exe?job=build-windows" -o funkwhale.exe
+```
+
+# Usage
+
+``funkwhale --help``
+
+# Installation (from source)
+
 This cli requires python 3.6 or greater:
 
     git clone https://dev.funkwhale.audio/funkwhale/cli.git
     cd cli
     pip install .
 
-# Usage
 
-``funkwhale --help``
+# Build the binary
+
+You can build the binarie for you platform using the following commands:
+
+    pip install .[dev]
+    pyinstaller cli.spec
+
+This will output a binary in `./dist/funkwhale`.
diff --git a/cli.spec b/cli.spec
new file mode 100644
index 0000000000000000000000000000000000000000..3116b1c844458af93f54a78717d930d394d59264
--- /dev/null
+++ b/cli.spec
@@ -0,0 +1,35 @@
+# -*- mode: python -*-
+
+block_cipher = None
+
+
+a = Analysis(
+    ["funkwhale_cli/cli.py"],
+    pathex=["/home/eliotberriot/projects/funkwhale/cli"],
+    binaries=[],
+    datas=[],
+    hiddenimports=[],
+    hookspath=[],
+    runtime_hooks=[],
+    excludes=[],
+    win_no_prefer_redirects=False,
+    win_private_assemblies=False,
+    cipher=block_cipher,
+    noarchive=False,
+)
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+exe = EXE(
+    pyz,
+    a.scripts,
+    a.binaries,
+    a.zipfiles,
+    a.datas,
+    [],
+    name="funkwhale",
+    debug=False,
+    bootloader_ignore_signals=False,
+    strip=False,
+    upx=True,
+    runtime_tmpdir=None,
+    console=True,
+)
diff --git a/funkwhale_cli/cli.py b/funkwhale_cli/cli.py
index fa892e39452385d8c852b857925d93a4a7ad6fb2..c3170d0130f80a7ea4d4fdcde6672aee5cd01059 100644
--- a/funkwhale_cli/cli.py
+++ b/funkwhale_cli/cli.py
@@ -7,6 +7,14 @@ import datetime
 import dotenv
 import functools
 import keyring
+
+# importing the backends explicitely is required for PyInstaller to work
+import keyring.backends.kwallet
+import keyring.backends.Windows
+import keyring.backends.OS_X
+import keyring.backends.SecretService
+import keyring.backends.chainer
+
 import logging
 import math
 import urllib.parse
@@ -16,11 +24,12 @@ import pathvalidate
 import pathlib
 import urllib.parse
 import tqdm
-from . import api
-from . import config
-from . import exceptions
-from . import logs
-from . import output
+
+from funkwhale_cli import api
+from funkwhale_cli import config
+from funkwhale_cli import exceptions
+from funkwhale_cli import logs
+from funkwhale_cli import output
 
 click_log.basic_config(logs.logger)
 
@@ -60,10 +69,18 @@ def async_command(f):
         _async_reraise = kwargs.pop("_async_reraise", False)
         try:
             return loop.run_until_complete(f(*args, **kwargs))
-        except (exceptions.FunkwhaleError, aiohttp.client_exceptions.ClientError) as e:
+        except (aiohttp.client_exceptions.ClientError) as e:
+            if _async_reraise:
+                raise
+            message = str(e)
+            if hasattr(e, 'status') and e.status == 401:
+                message = "Remote answered with {}, ensure your are logged in first".format(e.status)
+            raise click.ClickException(message)
+        except (exceptions.FunkwhaleError) as e:
             if _async_reraise:
                 raise
-            raise click.ClickException(str(e))
+            message = str(e)
+            raise click.ClickException(message)
         else:
             raise
 
@@ -118,14 +135,14 @@ class lazy_credential():
         return bool(self.value)
 
 
-def set_server(ctx, url, token):
+def set_server(ctx, url, token, use_auth=True):
     ctx.ensure_object(dict)
     ctx.obj["SERVER_URL"] = url
     parsed = urllib.parse.urlparse(url)
     ctx.obj["SERVER_NETLOC"] = parsed.netloc
     ctx.obj["SERVER_PROTOCOL"] = parsed.scheme
     try:
-        token = token or lazy_credential(url, "_")
+        token = (token or lazy_credential(url, "_")) if use_auth else None
     except ValueError as e:
         raise click.ClickException("Error while retrieving password from keyring: {}. Your password may be incorrect.".format(e.args[0]))
     except Exception as e:
@@ -154,14 +171,26 @@ def set_server(ctx, url, token):
     default=False,
     help="Disable logging",
 )
+@click.option(
+    "--no-login",
+    envvar="FUNKWHALE_NO_LOGIN",
+    is_flag=True,
+    default=False,
+    help="Disable authentication/keyring",
+)
 @SERVER_DECORATOR
 @TOKEN_DECORATOR
 @click_log.simple_verbosity_option(logs.logger, expose_value=True)
 @click.pass_context
-def cli(ctx, env_file, url, verbosity, token, quiet):
+def cli(ctx, env_file, url, verbosity, token, quiet, no_login):
+    # small hack to fix some weird issues with pyinstaller and keyring
+    # there seems to be a cache issue somewhere
+    del keyring.backend.get_all_keyring.__wrapped__.always_returns
+    keyring.core.init_backend()
+    # /end of hack
     ctx.ensure_object(dict)
     logs.logger.disabled = quiet
-    set_server(ctx, url, token)
+    set_server(ctx, url, token, use_auth=not no_login)
 
 
 @cli.command()
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000000000000000000000000000000000000..cd76dde93067bea93e8d81d5fa70ac13b10a907a
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,19 @@
+aiofiles
+aiohttp
+appdirs
+click
+click-log
+keyring
+marshmallow
+python-dotenv
+semver
+tabulate
+tqdm
+pathvalidate
+aioresponses
+asynctest
+ipdb
+pytest
+pytest-mock
+pytest-env
+pyinstaller
diff --git a/setup.cfg b/setup.cfg
index a32bac142ab34dc26ce0e5466b25d65a2c82b3be..37b52206a221db941a37498121cf842214dad928 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -44,7 +44,7 @@ dev =
     pytest
     pytest-mock
     pytest-env
-
+    pyinstaller
 
 
 [options.packages.find]