Skip to content


Cryptpad is an end-to-end encrypted cloud office suite that enables collaboration and includes Rich Text, Spreadsheets, Code/Markdown, Kanban, Slides, Whiteboard, Polls, and Cloud Storage.


sudo -s

Update apt and upgrade:

apt update && apt upgrade

Make sure git and curl are installed:

apt install git curl

If you haven't previously done so, install Node.js v12:

curl -sL | sudo -E bash -
apt install -y nodejs

Upgrade npm:

npm install -g npm

Install Bower with npm:

npm install -g bower

Create the user for the service:

useradd -m -d /var/www/ -s /bin/bash -p cryptpad cryptpad

Installing Cryptpad

Change to the newly created home directory for the user:

cd /var/www/

Clone the git repository:

sudo -u cryptpad git clone cryptpad

Move into the cryptpad directory and install all dependencies:

cd cryptpad
npm install
bower install

Copy the example config file and edit:

sudo -u cryptpad cp config/config.example.js config/config.js

At this point, I uncommented and set the following vavues:

    httpUnsafeOrigin: '',
    httpSafeOrigin: "",
    httpPort: 3001,
    httpSafePort: 3002,
    adminEmail: '',

By default, the ports are set to 3000 and 3001. I amended them slightly as I already had Gitea running on port 3000

Creating the certificates

After creating the DNS records in ClouDNS for,,, (I ended up not using the latter two), create the certificates as follows:

Issue the RSA certificates with:

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

and ECC certificates with:

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

Setting up Nginx

Create the server config as follows:

nano /etc/nginx/sites-available/

Enter the following:

server {
    listen 443 ssl http2;

    set $main_domain "";
    set $sandbox_domain "";

    set $api_domain "";
    set $files_domain "";


    ssl_certificate /etc/letsencrypt/ecc-certs/;
    ssl_certificate_key /etc/letsencrypt/ecc-certs/;
    ssl_certificate /etc/letsencrypt/rsa-certs/;
    ssl_certificate_key /etc/letsencrypt/rsa-certs/;
    ssl_trusted_certificate /etc/letsencrypt/ecc-certs/;

    ssl_dhparam /etc/ssl/certs/dhparam.pem; # openssl dhparam -out /etc/nginx/dhparam.pem 4096

    ssl_session_timeout 5m;
    ssl_session_cache shared:SSL_CP:5m;

    ssl_protocols TLSv1.2 TLSv1.3;

    ssl_ciphers EECDH+AESGCM:EDH+AESGCM;
    ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options nosniff;
    add_header Access-Control-Allow-Origin "*";

    # 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\]" ) {
        set $blockreason $bad_querystring;
        return 403;

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

    if ( $bad_request_method !~* "\[OK\]" ) {
        set $blockreason $bad_request_method;
        return 403;
    # End Bad Bot Blocker Includes

    set $coop '';
    if ($uri ~ ^\/sheet\/.*$) { set $coop 'same-origin'; }

    add_header Cross-Origin-Resource-Policy cross-origin;
    add_header Cross-Origin-Opener-Policy $coop;
    add_header Cross-Origin-Embedder-Policy require-corp;

    root /var/www/;
    index index.html;
    error_page 404 /customize.dist/404.html;

    # any static assets loaded with "ver=" in their URL will be cached for a year
    if ($args ~ ver=) {
        set $cacheControl max-age=31536000;
    if ($uri ~ ^/.*(\/|\.html)$) {
        set $cacheControl no-cache;

    add_header Cache-Control $cacheControl;

    set $styleSrc   "'unsafe-inline' 'self' ${main_domain}";
    set $connectSrc "'self' https://${main_domain} ${main_domain} https://${api_domain} blob: wss://${api_domain} ${api_domain} ${files_domain}";
    set $fontSrc    "'self' data: ${main_domain}";
    set $imgSrc     "'self' data: * blob: ${main_domain}";
    set $frameSrc   "'self' ${sandbox_domain} blob:";
    set $mediaSrc   "'self' data: * blob: ${main_domain}";
    set $childSrc   "https://${main_domain}";
    set $workerSrc  "https://${main_domain}";
    set $scriptSrc  "'self' resource: ${main_domain}";

    set $unsafe 0;

    if ($uri = "/sheet/inner.html") { set $unsafe 1; }
    if ($uri ~ ^\/common\/onlyoffice\/.*\/index\.html.*$) { set $unsafe 1; }

    if ($host != $sandbox_domain) { set $unsafe 0; }

    if ($unsafe) {
        set $scriptSrc "'self' 'unsafe-eval' 'unsafe-inline' resource: ${main_domain}";

    add_header Content-Security-Policy "default-src 'none'; child-src $childSrc; worker-src $workerSrc; media-src $mediaSrc; style-src $styleSrc; script-src $scriptSrc; connect-src $connectSrc; font-src $fontSrc; img-src $imgSrc; frame-src $frameSrc;";

    location ^~ /cryptpad_websocket {
        proxy_pass http://localhost:3001;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # WebSocket support (nginx 1.4)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection upgrade;

    # Deny access to registration page
    #location /register {
        #deny all;

    location ^~ /customize.dist/ {
        # This is needed in order to prevent infinite recursion between /customize/ and the root

    location ^~ /customize/ {
        rewrite ^/customize/(.*)$ $1 break;
        try_files /customize/$uri /customize.dist/$uri;

    location = /api/config {
        proxy_pass http://localhost:3001;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location ^~ /blob/ {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' '*';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'application/octet-stream; charset=utf-8';
            add_header 'Content-Length' 0;
            return 204;
        add_header Cache-Control max-age=31536000;
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length';
        add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length';
        try_files $uri =404;

    # the "block-store" serves encrypted payloads containing users' drive keys
    # these payloads are unlocked via login credentials. They are mutable
    # and are thus never cached. They're small enough that it doesn't matter, in any case.
    location ^~ /block/ {
        add_header Cache-Control max-age=0;
        try_files $uri =404;

    location ~ ^/(register|login|settings|user|pad|drive|poll|slide|code|whiteboard|file|media|profile|contacts|todo|filepicker|debug|kanban|sheet|support|admin|notifications|teams)$ {
        rewrite ^(.*)$ $1/ redirect;

    try_files /www/$uri /www/$uri/index.html /customize/$uri;

If you need a greater understanding of the above, an extensively commented example ngix config is available to view at

I have also added a section to disallow access to the registrtion page (comented out for now), and a section to disallow access to bad bots (you will need to have followed my earlier LEAMP Server guides for this to work)

Apparmor Profiles

Add the following line to your Nginx profile (I'm assuming you have one)

nano /etc/apparmor.d/usr.sbin.nginx
  /var/www/** r,
  /var/www/** r,
  /var/www/** r,
  /var/www/** r,
  /var/www/** r,

Now create a cryptpad profile:

nano /etc/apparmor.d/cryptpad

Enter the following:

#include <tunables/global>

profile cryptpad flags=(complain, attach_disconnected) {
  #include <abstractions/base>
  #include <abstractions/nameservice>
  #include <abstractions/ssl_keys>
  #include <abstractions/user-tmp>

  /sys/fs/cgroup/memory/memory.limit_in_bytes r,
  /usr/bin/node mrix,
  owner /proc/*/fd/ r,
  owner /var/www/ r,
  owner /var/www/**/ r,
  owner /var/www/** w,
  owner /var/www/** w,
  owner /var/www/ r,
  owner /var/www/**.ndjson rw,
  owner /var/www/ w,
  owner /var/www/*/ w,
  owner /var/www/** rw,
  owner /var/www/*/ rw,
  owner /var/www/** w,
  owner /var/www/**.ndjson rw,
  owner /var/www/*/ rw,
  owner /var/www/**.js r,
  owner /var/www/**.js r,
  owner /var/www/**.json r,
  owner /var/www/ r,
  owner /var/www/ r,
  owner /var/www/** r,


Reload apparmor:

service apparmor reload

After thoroughly testing, you can enforce the profile with:

aa-enforce cryptpad

Systemd Unit

Now we create a systemd unit in order to manage the service

nano /etc/systemd/system/cryptpad.service

Enter the following:

Description=CryptPad (The Zero Knowledge Cloud)

ExecStart=/usr/bin/node /var/www/

### Modify these two values and uncomment them if you have lots of files and get an HTTP error 500 because of that
### If you want to bind CryptPad to a port below 1024 uncomment the two values below

# Some extra security directives


Reload the systemd daemon:

systemctl daemon-reload

Enable the service, start, and then check:

systemctl enable cryptpad.service
service cryptpad start
service cryptpad status

Final steps

Enable the nginx site and reload:

ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/
service nginx reload

You can check for errors in the nginx config with:

nginx -t

Access the site at and create an account. Once logged in, click the button in the top right (should be the first letter of your username by default) and go to settings. Copy your public key from that page.

Now edit the cryptpad config file:

nano /var/www/

Uncomment the line to enable the administration panel for your account, replacing the example public key with your own. For example:

    adminKeys: [

Logout of cryptpad and restart the cryptpad service

service cryptpad restart

After logging back in, you should now see an administration entry in the menu