Kanban / Boards
    Docker

    Deploy Focalboard on a VPS

    Deploy Focalboard Personal Server — a self-hosted Trello and Notion alternative — on a RamNode KVM VPS with PostgreSQL and Nginx TLS. Includes maintenance status guidance.

    Focalboard is a self-hosted Trello and Notion alternative from the Mattermost ecosystem. Board, table, gallery, and calendar views, custom card properties, templates, and a REST API, all served by a single Go binary. It is small, fast, and pleasant to use.

    It is also unmaintained. Read section 1 before you deploy this.

    This guide covers Focalboard Personal Server 7.11.4 on Ubuntu 24.04 LTS with PostgreSQL, behind Nginx with Let's Encrypt.


    1. Read this first: maintenance status

    Mattermost stopped reviewing and merging pull requests for Focalboard and the Boards plugin on September 15, 2023. The repository moved to mattermost-community/focalboard and is community supported. The last standalone release, 7.11.4, dates from July 2023. The mattermost/focalboard Docker image has not been rebuilt since.

    That means:

    • No upstream security patches. Any CVE in the Go stdlib, the bundled JS dependencies, or the app itself sits unfixed unless a community fork picks it up.
    • The base image and its packages are three years stale.
    • Nothing new is coming. What you deploy is what you get, permanently.

    The codebase is stable and the app works. Plenty of people are still running it. But "works" and "safe to expose to the internet" are different claims, and only the first one is true here.

    Deploy Focalboard if:

    • It is behind a VPN, Tailscale, or an authenticating proxy, not on the open internet.
    • The data is low sensitivity.
    • You accept that you own it now.

    Look at the alternatives if:

    • You want a maintained product. Mattermost Boards (mattermost/mattermost-plugin-boards) is the living descendant, and it requires a Mattermost server.
    • You want a maintained standalone. Vikunja and Plane are both actively developed and both have Kanban.

    If you still want Focalboard on a public hostname, section 7 covers putting an auth layer in front of it. Do that, not the naked deployment.


    2. Pick the right RamNode plan

    Focalboard is genuinely tiny. The Go server idles around 50 MB.

    UsersRamNode sizingDatabase
    11 GB RAM, 1 vCPU, 20 GB SSDSQLite
    2 to 202 GB RAM, 1 vCPU, 40 GB SSDPostgreSQL

    SQLite does not handle concurrent writes well and Focalboard is write-heavy when several people drag cards around. Use Postgres for anything past a single user.

    Any KVM plan works. Pick the closest region.


    3. Prepare the VPS

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates ufw fail2ban
    timedatectl set-timezone UTC
    adduser deploy
    usermod -aG sudo deploy
    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    Point an A record for boards.example.com at the VPS. Verify with dig +short boards.example.com.

    Install Docker:

    shell
    curl -fsSL https://get.docker.com | sh -
    usermod -aG docker deploy
    systemctl enable --now docker
    docker compose version

    4. Deploy

    Log in as deploy.

    shell
    mkdir -p ~/focalboard && cd ~/focalboard

    Create .env:

    shell
    cat > .env <<'EOF'
    DB_PASSWORD=CHANGE_ME
    EOF
    chmod 600 .env

    Generate a password with openssl rand -hex 24.

    Create config.json. The image ships a default at /opt/focalboard/config.json and you are replacing it:

    shell
    {
      "serverRoot": "https://boards.example.com",
      "port": 8000,
      "dbtype": "postgres",
      "dbconfig": "postgres://focalboard:YOUR_DB_PASSWORD@focalboard-db:5432/focalboard?sslmode=disable",
      "useSSL": false,
      "webpath": "./pack",
      "filespath": "/opt/focalboard/data/files",
      "telemetry": false,
      "prometheus_address": "",
      "session_expire_time": 2592000,
      "session_refresh_time": 18000,
      "localOnly": false,
      "enableLocalMode": true,
      "localModeSocketLocation": "/var/tmp/focalboard_local.socket",
      "enablePublicSharedBoards": false
    }

    The fields that matter:

    • serverRoot must be the public HTTPS URL. Get it wrong and websockets fail, which manifests as boards that do not update until you refresh.
    • useSSL stays false. Nginx terminates TLS, not Focalboard.
    • dbconfig password must match .env. Focalboard reads config.json literally and does not expand environment variables, which is why the password is duplicated. Keep config.json at mode 600 and out of version control.
    • enablePublicSharedBoards: false unless you specifically want anonymous read-only share links. Given the maintenance status, leave it off.
    • telemetry: false to stop it phoning home.
    shell
    chmod 600 config.json

    Create docker-compose.yml:

    shell
    services:
      focalboard-db:
        image: postgres:15-alpine
        restart: unless-stopped
        environment:
          POSTGRES_DB: focalboard
          POSTGRES_USER: focalboard
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes:
          - pgdata:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U focalboard -d focalboard"]
          interval: 10s
          timeout: 5s
          retries: 10
    
      focalboard:
        image: mattermost/focalboard:7.11.4
        restart: unless-stopped
        ports:
          - "127.0.0.1:8000:8000"
        volumes:
          - fbdata:/opt/focalboard/data
          - ./config.json:/opt/focalboard/config.json:ro
        depends_on:
          focalboard-db:
            condition: service_healthy
    
    volumes:
      pgdata:
      fbdata:

    Pin 7.11.4. Do not use latest. On an abandoned project a moving tag buys you nothing and costs you reproducibility.

    Bind to 127.0.0.1 explicitly. Docker publishes ports by writing into the DOCKER-USER iptables chain and bypasses UFW completely. Without that bind, an unauthenticated Focalboard is on the internet at http://YOUR-IP:8000 no matter what UFW says.

    The container runs as nobody. Use named volumes as above rather than bind mounts, and you avoid a permissions fight.

    Start it:

    shell
    docker compose up -d
    docker compose logs -f focalboard
    curl -I http://127.0.0.1:8000

    Verify from off-box that port 8000 is closed:

    shell
    curl -m 5 -I http://YOUR-VPS-IP:8000

    That must time out.


    5. Nginx and Let's Encrypt

    shell
    sudo apt install -y nginx certbot python3-certbot-nginx

    /etc/nginx/sites-available/focalboard:

    shell
    upstream focalboard {
        server 127.0.0.1:8000;
        keepalive 32;
    }
    
    server {
        listen 80;
        server_name boards.example.com;
        return 301 https://$host$request_uri;
    }
    
    server {
        listen 443 ssl http2;
        server_name boards.example.com;
    
        client_max_body_size 50M;
    
        location ~ /ws/* {
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            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_http_version 1.1;
            proxy_read_timeout 600s;
            proxy_pass http://focalboard;
        }
    
        location / {
            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 Connection "";
            proxy_http_version 1.1;
            proxy_read_timeout 60s;
            proxy_pass http://focalboard;
        }
    }

    The /ws/ block is not optional. Focalboard pushes every board change over a websocket. Without the upgrade headers the app loads, you can create cards, and nothing another user does ever appears until a manual refresh. That is the number one Focalboard proxy complaint and it is always this.

    shell
    sudo ln -s /etc/nginx/sites-available/focalboard /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t && sudo systemctl reload nginx
    sudo certbot --nginx -d boards.example.com

    6. First login

    Open https://boards.example.com and register. In Personal Server mode the first account is yours and there is no separate admin console.

    Focalboard's Personal Server has no invitation flow and no SMTP. It has no password reset either, because there is nothing to send the reset with. Users register themselves, or you create them, and if someone forgets a password you fix it in the database.

    To close registration after your users are in, put an auth layer in front. See below.


    7. Put something in front of it

    Given no security patches since 2023, treat an internet-facing Focalboard as a temporary state, not a design.

    Options in rough order of effort:

    Tailscale or WireGuard. Best answer. Bind Focalboard to the tailnet interface, remove 80 and 443 from UFW, skip Nginx and certbot entirely. If your team already has a mesh VPN, this takes ten minutes and eliminates the whole problem.

    IP allowlist in Nginx. If your users have static IPs:

    shell
        allow 203.0.113.0/24;
        deny all;

    Basic auth in front. Crude, but it means an unauthenticated attacker never reaches Focalboard's own code:

    shell
    sudo apt install -y apache2-utils
    sudo htpasswd -c /etc/nginx/.htpasswd yourname
    shell
        auth_basic "Boards";
        auth_basic_user_file /etc/nginx/.htpasswd;

    You will need to exempt /ws/ or the websocket handshake fails, which weakens it. Prefer the VPN.

    A real auth proxy. oauth2-proxy or Authelia in front of Nginx gives you SSO and MFA and is the right answer if this is a team tool that must be public.

    Also run fail2ban against the Nginx access log, and keep the host itself patched even though the container will not be.


    8. Backups

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M)
    DEST=/var/backups/focalboard
    APP=/home/deploy/focalboard
    mkdir -p "$DEST"
    
    docker compose -f "$APP/docker-compose.yml" exec -T focalboard-db \
      pg_dump -U focalboard -d focalboard | gzip > "$DEST/fb-db-$STAMP.sql.gz"
    
    docker run --rm \
      -v focalboard_fbdata:/data:ro \
      -v "$DEST":/backup \
      alpine tar czf "/backup/fb-files-$STAMP.tar.gz" -C /data .
    
    cp "$APP/config.json" "$DEST/config-$STAMP.json"
    
    find "$DEST" -type f -mtime +21 -delete

    Confirm the real volume name with docker volume ls first. Compose prefixes it with the project directory name.

    shell
    sudo install -m 700 focalboard-backup.sh /usr/local/bin/
    sudo crontab -e
    # 0 3 * * * /usr/local/bin/focalboard-backup.sh >> /var/log/focalboard-backup.log 2>&1

    Focalboard's per-board export is a UI action and only covers one board at a time. It is fine for moving a board between instances, useless as a backup strategy. The Postgres dump plus the files volume is the real backup.

    Ship a copy off the VPS.


    9. Upgrades

    There are none. 7.11.4 is the last release.

    What you can do:

    • Update Postgres and Nginx normally. Those are maintained.
    • Watch for a community fork that rebuilds the image against a current Go toolchain and dependency set. If one appears with real activity behind it, evaluate switching. Verify who is behind it before you point your data at a random Docker Hub account.
    • Plan the exit. Focalboard's API and per-board exports give you a path out. Do not let this become the tool nobody can leave.

    10. Troubleshooting

    Boards do not update in real time for other users. The /ws/ location block. Every time.

    Login works, then immediate logout. serverRoot does not match the URL in the browser. Fix config.json, restart the container.

    Container will not start, database errors in the log. The dbconfig string in config.json and DB_PASSWORD in .env disagree. They are two separate places holding the same secret. Reconcile them.

    Files upload but do not appear. filespath must be inside the mounted volume. /opt/focalboard/data/files matches the fbdata volume above.

    Container starts, then exits. Check docker compose logs focalboard. Usually config.json is malformed JSON. A trailing comma kills it.

    Forgotten password. No reset flow exists in Personal Server. Hash a new bcrypt password and update the users table directly, or delete the user and re-register.

    shell
    docker compose logs -f focalboard
    docker compose exec focalboard-db psql -U focalboard -d focalboard -c '\dt'

    The honest recommendation

    Focalboard is a nice piece of software that stopped being maintained three years ago. If you want it for a personal Kanban board behind Tailscale, deploy it and enjoy it. It costs almost nothing to run.

    If you are choosing a board tool for a team that will still exist in two years, deploy Vikunja instead. It has Kanban, it is one container, it runs on the same 2 GB RamNode plan, and someone is still fixing its bugs.