Skip to content

Funkwhale

Installing and Creating the Database

First, drop into a sudo shell and install PostgreSQL:

sudo -s
apt update && apt install postgresql postgresql-contrib

Open a database shell:

sudo -u postgres psql

And create the database:

CREATE DATABASE "funkwhale"
  WITH ENCODING 'utf8';
CREATE USER funkwhale;
GRANT ALL PRIVILEGES ON DATABASE funkwhale TO funkwhale;

Create the funkwhale system user:

useradd -r -s /usr/sbin/nologin -d /var/www/audio.example.com -m funkwhale

Enable some extensions:

sudo -u postgres psql funkwhale -c 'CREATE EXTENSION "unaccent";'
sudo -u postgres psql funkwhale -c 'CREATE EXTENSION "citext";'

Redis

This section assumes that you've already set Redis up during the RSpamd tutorial.

Exit the funkwhale login session:

exit

Generate a password:

pwgen -Bvsc 64 1

This results in something like the following:

rtVh7zMTFHWm7HWVFXfcgNLXXtjC9scTt4mHVwjnrvqwsJ9qv9CjdwcCWtpqNVbL

Create a config:

nano /etc/redis/redis-funkwhale.conf

Enter the following, using the password you generated above:

include /etc/redis/common.conf
# Listen on localhost
bind 127.0.0.1 ::1
port 6383
unixsocket /var/run/redis-funkwhale/redis-server.sock
unixsocketperm 700
daemonize yes
supervised systemd
pidfile /var/run/redis-funkwhale/redis-server.pid
loglevel notice
logfile /var/log/redis/redis-funkwhale.log
dbfilename dump-funkwhale.rdb
requirepass <enter password here>
# maxmemory <bytes>
# maxmemory-policy noeviction
# maxmemory-samples 5

Change permissions on the configuration files:

chown -Rc redis:redis /etc/redis

Enable and start the services

systemctl enable redis-server@funkwhale
systemctl start redis-server@funkwhale.service

Check that the service are running with:

ps aux | grep redis

or

service redis-server@funkwhale status

Installing Funkwhale

Install dependencies for Funkwhale:

apt install curl python3-pip python3-venv git unzip libldap2-dev libsasl2-dev gettext-base zlib1g-dev libffi-dev libssl-dev 
apt install build-essential ffmpeg libjpeg-dev libmagic-dev libpq-dev postgresql-client python3-dev make

Change to the funkwhale directory and login as the funkwhale user:

cd /var/www/audio.example.com
sudo -u funkwhale -H bash

Create the directory structure:

mkdir -p config api data/static data/media data/music front

Download the funkwhale API:

curl -L -o "api-1.0.1.zip" "https://dev.funkwhale.audio/funkwhale/funkwhale/-/jobs/artifacts/1.0.1/download?job=build_api"
unzip "api-1.0.1.zip" -d extracted
mv extracted/api/* api/
rm -rf extracted

Download the frontend files:

curl -L -o "front-1.0.1.zip" "https://dev.funkwhale.audio/funkwhale/funkwhale/-/jobs/artifacts/1.0.1/download?job=build_front"
unzip "front-1.0.1.zip" -d extracted
mv extracted/front .
ls

Make sure you're still in the base directory:

cd /var/www/audio.example.com

To avoid collisions with other software on your system, Python dependencies will be installed in a dedicated virtualenv.

First, create the virtualenv:

python3 -m venv /var/www/audio.example.com/virtualenv

In the rest of this guide, we’ll need to activate this environment to ensure dependencies are installed within it, and not directly on your host system.

This is done with the following command:

source /var/www/audio.example.com/virtualenv/bin/activate

Finally, install the python dependencies:

pip install wheel
pip install -r api/requirements.txt

Important

Further commands involving python should always be run after you activated the virtualenv, as described earlier, otherwise those commands will raise errors

Environment File

Download the sample environment file:

curl -L -o config/.env "https://dev.funkwhale.audio/funkwhale/funkwhale/raw/master/deploy/env.prod.sample"

Generate a secret key for Django:

openssl rand -base64 45

Reduce permissions on the .env file since it contains sensitive data. You can then edit the file: the file is heavily commented, and the most relevant configuration options are mentioned at the top of the file.

chmod 600 /var/www/audio.example.com/config/.env 
nano /var/www/audio.example.com/config/.env

Paste the secret key you generated earlier at the entry DJANGO_SECRET_KEY and populate the DATABASE_URL and CACHE_URL values based on how you configured your PostgreSQL and Redis servers.

An example of a .env file follows:

# If you have any doubts about what a setting does,
# check https://docs.funkwhale.audio/configuration.html#configuration-reference

# If you're tweaking this file from the template, ensure you edit at least the
# following variables:
# - DJANGO_SECRET_KEY
# - FUNKWHALE_HOSTNAME
# - EMAIL_CONFIG and DEFAULT_FROM_EMAIL if you plan to send emails)
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
#
# You **don't** need to update those variables on pure docker setups.
#
# Additional options you may want to check:
# - MUSIC_DIRECTORY_PATH and MUSIC_DIRECTORY_SERVE_PATH if you plan to use
#   in-place import
#
# Docker only
# -----------

# The tag of the image we should use
# (it will be interpolated in docker-compose file)
# You can comment or ignore this if you're not using docker
FUNKWHALE_VERSION=latest

# End of Docker-only configuration

# General configuration
# ---------------------

# Set this variables to bind the API server to another interface/port
# example: FUNKWHALE_API_IP=0.0.0.0
# example: FUNKWHALE_API_PORT=5678
FUNKWHALE_API_IP=127.0.0.1
FUNKWHALE_API_PORT=5678
# The number of web workers to start in parallel. Higher means you can handle
# more concurrent requests, but also leads to higher CPU/Memory usage
FUNKWHALE_WEB_WORKERS=1
# Replace this by the definitive, public domain you will use for
# your instance
FUNKWHALE_HOSTNAME=audio.example.com
FUNKWHALE_PROTOCOL=https

# Configure email sending using this variale
# By default, funkwhale will output emails sent to stdout
# here are a few examples for this setting
# EMAIL_CONFIG=consolemail://         # output emails to console (the default)
# EMAIL_CONFIG=dummymail://          # disable email sending completely
# On a production instance, you'll usually want to use an external SMTP server:
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587
EMAIL_CONFIG=smtp+tls://user@example.com:your-email-password@mail.example.com:587

# The email address to use to send system emails.
DEFAULT_FROM_EMAIL=Funkwhale <noreply@example.com>

# Depending on the reverse proxy used in front of your funkwhale instance,
# the API will use different kind of headers to serve audio files
# Allowed values: nginx, apache2
REVERSE_PROXY_TYPE=nginx

# API/Django configuration

# Database configuration
# Examples:
#  DATABASE_URL=postgresql://<user>:<password>@<host>:<port>/<database>
#  DATABASE_URL=postgresql://funkwhale:passw0rd@localhost:5432/funkwhale_database
# Use the next one if you followed Debian installation guide
DATABASE_URL=postgresql://funkwhale@:5432/funkwhale

# Cache configuration
# Examples:
#  CACHE_URL=redis://<host>:<port>/<database>
#  CACHE_URL=redis://localhost:6379/0c
#  With a password:
#  CACHE_URL=redis://:password@localhost:6379/0
#  (the extra semicolon is important)
# Use the next one if you followed Debian installation guide
#
CACHE_URL=redis://:<your-redis-password>@127.0.0.1:6383/0
#
# If you want to use Redis over unix sockets, you'll actually need two variables:
# For the cache part:
#  CACHE_URL=redis:///run/redis/redis.sock?db=0
# For the Celery/asynchronous tasks part:
#  CELERY_BROKER_URL=redis+socket:///run/redis/redis.sock?virtual_host=0

# Number of worker processes to execute. Defaults to 0, in which case it uses your number of CPUs
# Celery workers handle background tasks (such file imports or federation
# messaging). The more processes a worker gets, the more tasks
# can be processed in parallel. However, more processes also means
# a bigger memory footprint.
CELERYD_CONCURRENCY=0

# Where media files (such as album covers or audio tracks) should be stored
# on your system?
# (Ensure this directory actually exists)
MEDIA_ROOT=/var/www/audio.example.com/data/media

# Where static files (such as API css or icons) should be compiled
# on your system?
# (Ensure this directory actually exists)
STATIC_ROOT=/var/www/audio.example.com/data/static
MEDIA_URL=https://audio.example.com/media/
# which settings module should django use?
# You don't have to touch this unless you really know what you're doing
DJANGO_SETTINGS_MODULE=config.settings.production

# Generate one using `openssl rand -base64 45`, for example
DJANGO_SECRET_KEY=<key that you previously generated>

# You don't have to edit this, but you can put the admin on another URL if you
# want to
# DJANGO_ADMIN_URL=^api/admin/

# Sentry/Raven error reporting (server side)
# Enable Raven if you want to help improve funkwhale by
# automatically sending error reports our Sentry instance.
# This will help us detect and correct bugs
RAVEN_ENABLED=false
RAVEN_DSN=https://876463265989857398579879875986374896263@sentry.eliotberriot.com/5

# In-place import settings
# You can safely leave those settings uncommented if you don't plan to use
# in place imports.
# Typical docker setup:
#   MUSIC_DIRECTORY_PATH=/music  # docker-only
#   MUSIC_DIRECTORY_SERVE_PATH=/srv/funkwhale/data/music
# Typical non-docker setup:
#   MUSIC_DIRECTORY_PATH=/srv/funkwhale/data/music
#   # MUSIC_DIRECTORY_SERVE_PATH= # stays commented, not needed

MUSIC_DIRECTORY_PATH=/var/www/audio.example.com/data/music
MUSIC_DIRECTORY_SERVE_PATH=/var/www/audio.example.com/data/music

# LDAP settings
# Use the following options to allow authentication on your Funkwhale instance
# using a LDAP directory.
# Have a look at https://docs.funkwhale.audio/installation/ldap.html for
# detailed instructions.

# LDAP_ENABLED=False
# LDAP_SERVER_URI=ldap://your.server:389
# LDAP_BIND_DN=cn=admin,dc=domain,dc=com
# LDAP_BIND_PASSWORD=bindpassword
# LDAP_SEARCH_FILTER=(|(cn={0})(mail={0}))
# LDAP_START_TLS=False
# LDAP_ROOT_DN=dc=domain,dc=com

FUNKWHALE_FRONTEND_PATH=/var/www/audio.example.com/front/dist

# Nginx related configuration
NGINX_MAX_BODY_SIZE=100M

## External storages configuration
# Funkwhale can store uploaded files on Amazon S3 and S3-compatible storages (such as Minio)
# Uncomment and fill the variables below

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_STORAGE_BUCKET_NAME=
# An optional bucket subdirectory were you want to store the files. This is especially useful
# if you plan to use share the bucket with other services
# AWS_LOCATION=

# If you use a S3-compatible storage such as minio, set the following variable
# the full URL to the storage server. Example:
#   AWS_S3_ENDPOINT_URL=https://minio.mydomain.com
# AWS_S3_ENDPOINT_URL=

# If you want to serve media directly from your S3 bucket rather than through a proxy,
# set this to true
# PROXY_MEDIA=false

# If you are using Amazon S3 to serve media directly, you will need to specify your region
# name in order to access files. Example:
#   AWS_S3_REGION_NAME=eu-west-2
# AWS_S3_REGION_NAME=

# If you are using Amazon S3, use this setting to configure how long generated URLs should stay
# valid. The default value is 3600 (60 minutes). The maximum accepted value is 604800 (7 days)

# AWS_QUERYSTRING_EXPIRE=

Database setup

You should now be able to import the initial database structure. First activate the virtual environment, making sure you're still logged in as the funkwhale user, and then run:

~/virtualenv/bin/python api/manage.py migrate
This will create the required tables and rows.

Note

You can safely execute this command any time you want, this will only run unapplied migrations.

Warning

You may sometimes get the following warning while applying migrations:

Your models have changes that are not yet reflected in a migration, and so won't be applied.
This is a warning, not an error, and it can be safely ignored. Never run the makemigrations command yourself.

Create an admin account

You can then create your first user account:

~/virtualenv/bin/python api/manage.py createsuperuser

If you ever want to change a user’s password from the command line, just run:

~/virtualenv/bin/python api/manage.py changepassword <user>

Collect static files

Static files are the static assets used by the API server (icon PNGs, CSS, etc.). We need to collect them explicitly, so they can be served by the webserver:

~/virtualenv/bin/python api/manage.py collectstatic

This should populate the directory you choose for the STATIC_ROOT variable in your .env file.

Certificates

Exit back to the root sudo shell.

Issue the RSA certificates with:

~/.acme.sh/acme.sh --issue --dns dns_cloudns -d audio.example.com --keylength 4096 --key-file /etc/letsencrypt/rsa-certs/audio.example.com/privkey.pem --ca-file /etc/letsencrypt/rsa-certs/audio.example.com/chain.pem --cert-file /etc/letsencrypt/rsa-certs/audio.example.com/cert.pem --fullchain-file /etc/letsencrypt/rsa-certs/audio.example.com/fullchain.pem --pre-hook "mkdir -p /etc/letsencrypt/rsa-certs/audio.example.com" --post-hook "find /etc/letsencrypt/rsa-certs/audio.example.com/ -name '*.pem' -type f -exec chmod 600 {} \;" --renew-hook "find /etc/letsencrypt/rsa-certs/audio.example.com/ -name '*.pem' -type f -exec chmod 600 {} \; -exec service nginx reload \;"

and ECC certificates with:

~/.acme.sh/acme.sh --issue --dns dns_cloudns -d audio.example.com --keylength ec-384 --key-file /etc/letsencrypt/ecc-certs/audio.example.com/privkey.pem --ca-file /etc/letsencrypt/ecc-certs/audio.example.com/chain.pem --cert-file /etc/letsencrypt/ecc-certs/audio.example.com/cert.pem --fullchain-file /etc/letsencrypt/ecc-certs/audio.example.com/fullchain.pem --pre-hook "mkdir -p /etc/letsencrypt/ecc-certs/audio.example.com" --post-hook "find /etc/letsencrypt/ecc-certs/audio.example.com/ -name '*.pem' -type f -exec chmod 600 {} \;" --renew-hook "find /etc/letsencrypt/ecc-certs/audio.example.com/ -name '*.pem' -type f -exec chmod 600 {} \; -exec service nginx reload \;"

Nginx Reverse Proxy

Create the config:

nano /etc/nginx/sites-available/audio.example.com

Enter the following:

# This file was generated from funkwhale.template

upstream funkwhale-api {
    # depending on your setup, you may want to update this
    server 127.0.0.1:5678;
}

server {
    listen 80;
    listen [::]:80;
    # update this to match your instance name
    server_name audio.example.com;
    # useful for Let's Encrypt
    location /.well-known/acme-challenge/ { allow all; }
    location / { return 301 https://$host$request_uri; }
}

# required for websocket support
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen      443 ssl http2;
    listen [::]:443 ssl http2;
    server_name audio.example.com;

    ssl_certificate /etc/letsencrypt/rsa-certs/audio.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/rsa-certs/audio.example.com/privkey.pem;
    ssl_certificate /etc/letsencrypt/ecc-certs/audio.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/ecc-certs/audio.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/ecc-certs/audio.example.com/chain.pem;

    include /etc/nginx/custom-config/ssl.conf;

    # HSTS
    add_header Strict-Transport-Security "max-age=31536000";

    # Nginx Bad Bot Blocker Includes
    include /etc/nginx/bots.d/ddos.conf;
    include /etc/nginx/bots.d/blockbots.conf;


    if ($allowed_country = no) {
        set $blockreason '[geo_blocked]';
        return 403;
    }

    if ( $bad_querystring !~* "\[OK\]|\[bad_querystring_rule_33\]" ) {
        set $blockreason $bad_querystring;
        return 403;
    }

    if ( $bad_request !~* "\[OK\]|\[bad_request_rule_6\]" ) {
        set $blockreason $bad_request;
        return 403;
    }

    if ( $bad_request_method !~* "[\OK\]" ) {
        set $blockreason $bad_request_method;
        return 403;
    }

    # If you are using S3 to host your files, remember to add your S3 URL to the
    # media-src and img-src headers (e.g. img-src 'self' https://<your-S3-URL> data:)

    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    root /var/www/audio.example.com/front/dist;

    # compression settings
    gzip on;
    gzip_comp_level    5;
    gzip_min_length    256;
    gzip_proxied       any;
    gzip_vary          on;

    gzip_types
        application/javascript
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

    # end of compression settings
    location / {
        include /etc/nginx/custom-config/funkwhale_proxy.conf;
        # this is needed if you have file import via upload enabled
        client_max_body_size 100M;
        proxy_pass   http://funkwhale-api/;
    }

    location /front/ {
        add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
        add_header Referrer-Policy "strict-origin-when-cross-origin";
        add_header Service-Worker-Allowed "/";
        add_header X-Frame-Options "SAMEORIGIN";
        alias /var/www/audio.example.com/front/dist/;
        expires 30d;
        add_header Pragma public;
        add_header Cache-Control "public, must-revalidate, proxy-revalidate";
    }
    location /front/embed.html {
        add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
        add_header Referrer-Policy "strict-origin-when-cross-origin";

        add_header X-Frame-Options "ALLOW";
        alias /var/www/audio.example.com/front/dist/embed.html;
        expires 30d;
        add_header Pragma public;
        add_header Cache-Control "public, must-revalidate, proxy-revalidate";
    }

    location /federation/ {
        include /etc/nginx/custom-config/funkwhale_proxy.conf;
        proxy_pass   http://funkwhale-api/federation/;
    }

    # You can comment this if you do not plan to use the Subsonic API
    location /rest/ {
        include /etc/nginx/custom-config/funkwhale_proxy.conf;
        proxy_pass   http://funkwhale-api/api/subsonic/rest/;
    }

    location /.well-known/ {
        include /etc/nginx/custom-config/funkwhale_proxy.conf;
        proxy_pass   http://funkwhale-api/.well-known/;
    }

    location /media/ {
        alias /var/www/audio.example.com/data/media/;
    }

    location /_protected/media {
        # this is an internal location that is used to serve
        # audio files once correct permission / authentication
        # has been checked on API side
        internal;
        alias   /var/www/audio.example.com/data/media;
    }

    # Comment the previous location and uncomment this one if you're storing
    # media files in a S3 bucket
    # location ~ /_protected/media/(.+) {
    #     internal;
    #     # Needed to ensure DSub auth isn't forwarded to S3/Minio, see #932
    #     proxy_set_header Authorization "";
    #     proxy_pass $1;
    # }

    location /_protected/music {
        # this is an internal location that is used to serve
        # audio files once correct permission / authentication
        # has been checked on API side
        # Set this to the same value as your MUSIC_DIRECTORY_PATH setting
        internal;
        alias   /var/www/audio.example.com/data/music;
    }

    location /staticfiles/ {
        # django static files
        alias /var/www/audio.example.com/data/static/;
    }
}

Create the proxy config:

nano /etc/nginx/custom-config/funkwhale_proxy.conf

Enter the following:

# global proxy conf
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;

# websocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

Enable the site and reload:

ln -s /etc/nginx/sites-available/audio.example.com /etc/nginx/sites-enabled/
service nginx reload

Systemd Services

Create the target service file:

nano /etc/systemd/system/funkwhale.target

Enter the following:

[Unit]
Description=Funkwhale
Wants=funkwhale-server.service funkwhale-worker.service funkwhale-beat.service

Create the Funkwhale Server service:

nano /etc/systemd/system/funkwhale-server.service

Enter the following:

[Unit]
Description=Funkwhale application server
After=redis.service postgresql.service
PartOf=funkwhale.target

[Service]
User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/var/www/audio.example.com/api
EnvironmentFile=/var/www/audio.example.com/config/.env
ExecStart=/var/www/audio.example.com/virtualenv/bin/gunicorn config.asgi:application -w ${FUNKWHALE_WEB_WORKERS} -k uvicorn.workers.UvicornWorker -b ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}
[Install]
WantedBy=multi-user.target

Create the Funkwhale Worker service:

nano /etc/systemd/system/funkwhale-worker.service

Enter the following:

[Unit]
Description=Funkwhale celery worker
After=redis.service postgresql.service
PartOf=funkwhale.target

[Service]
User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/var/www/audio.example.com/api
EnvironmentFile=/var/www/audio.example.com/config/.env
ExecStart=/var/www/audio.example.com/virtualenv/bin/celery -A funkwhale_api.taskapp worker -l INFO --concurrency=${CELERYD_CONCURRENCY}


[Install]
WantedBy=multi-user.target

Create the Funkwhale Beat service:

nano /etc/systemd/system/funkwhale-beat.service

Enter the following:

[Unit]
Description=Funkwhale celery beat process
After=redis.service postgresql.service
PartOf=funkwhale.target

[Service]
User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/var/www/audio.example.com/api
EnvironmentFile=/var/www/audio.example.com/config/.env
ExecStart=/var/www/audio.example.com/virtualenv/bin/celery -A funkwhale_api.taskapp beat -l INFO

[Install]
WantedBy=multi-user.target

Reload Systemd:

systemctl daemon-reload

And start the services:

systemctl start funkwhale.target

To ensure all Funkwhale processes are started automatically after a reboot, run:

systemctl enable funkwhale-server
systemctl enable funkwhale-worker
systemctl enable funkwhale-beat

You can check the statuses of all processes like this:

systemctl status funkwhale-\*

Last.FM API

Create an account with Last.fm and create an API key at:

https://www.last.fm/api

Make sure you make a note of the keys, they only display once.

Add the following to your .env file:

FUNKWHALE_PLUGINS=funkwhale_api.contrib.scrobbler
FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_KEY=<your_api_key>
FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_SECRET=<your_api_secret>

In the UI, go to /settings then manage plugins, and configure the scrobbler plugin for your user.

Apparmor

Create the apparmor profile for celery:

nano /etc/apparmor.d/funkwhale-celery

Enter the following:

#include <tunables/global>

profile funkwhale-celery /var/www/audio.example.com/virtualenv/bin/celery flags=(complain) {
  #include <abstractions/base>
  #include <abstractions/bash>
  #include <abstractions/nameservice>
  #include <abstractions/python>
  #include <abstractions/user-tmp>

  capability dac_read_search,

  /etc/ldap/ldap.conf r,
  /etc/lsb-release r,
  /etc/magic r,
  /etc/mime.types r,
  /etc/python3.*/sitecustomize.py r,
  /lib/x86_64-linux-gnu/ld-*.so mr,
  /proc/loadavg r,
  /run/postgresql/* rw,
  /run/uuidd/request rw,
  /usr/bin/dash mrix,
  /usr/bin/python3.* rix,
  /usr/bin/uname mrix,
  /usr/sbin/ldconfig{,.real} mrix,
  /var/www/audio.example.com/virtualenv/bin/celery r,

  owner /dev/shm/* rwl,
  owner /proc/*/fd/ r,
  owner /var/www/audio.example.com/** r,
  owner /var/www/audio.example.com/**/__pycache__/*.pyc{,.*} rw,
  owner /var/www/audio.example.com/api/*.db rw,
  owner /var/www/audio.example.com/api/celerybeat-schedule rwk,
  owner /var/www/audio.example.com/api/celerybeat.pid rw,
  owner /var/www/audio.example.com/data/media/** rwk,
  owner /var/www/audio.example.com/virtualenv/lib/python3.*/**/*.so{,.*} mr,

}

Create the apparmor profile for gunicorn:

nano /etc/apparmor.d/funkwhale-gunicorn

Enter the following:

#include <tunables/global>

profile funkwhale-gunicorn /var/www/audio.example.com/virtualenv/bin/gunicorn flags=(complain) {
  #include <abstractions/base>
  #include <abstractions/bash>
  #include <abstractions/nameservice>
  #include <abstractions/openssl>
  #include <abstractions/user-tmp>

  capability dac_read_search,

  /etc/ldap/ldap.conf r,
  /etc/magic r,
  /etc/mime.types r,
  /etc/python3.*/sitecustomize.py r,
  /lib/x86_64-linux-gnu/ld-*.so mr,
  /run/postgresql/* rw,
  /usr/bin/dash rix,
  /usr/bin/python3.* ix,
  /usr/bin/uname mrix,
  /usr/sbin/ldconfig{,.real} mrix,
  /var/www/audio.example.com/virtualenv/bin/gunicorn r,
  /{usr/,}lib{,32,64}/** mr,

  owner /proc/*/fd/ r,
  owner /var/www/audio.example.com/** r,
  owner /var/www/audio.example.com/data/media/** rwk,
  owner /var/www/audio.example.com/virtualenv/lib/python3.*/**/*.so{,.*} mr,

}

You will also need to add the following two lines to your Nginx profile:

  /var/www/audio.example.com/data/media/** r,
  /var/www/audio.example.com/front/dist/** r,
  /var/www/audio.example.com/data/static/admin/** r,

Use aa-logprof to monitor and amend whilst using Funkwhale, and enforce with `aa-enforce`` once you're happy it's working.