An internal chat service is a great way to help your users connect with each other. There are many good options available online, both via SaaS and self-hosting. However, when self-hosting for many users, the effort required to do user account management may be prohibitive. This guide helps you skip that, in the case that your many users are spread out over only a few locations (and therefore, internet connections). We limit the availability of the chat service to only those IP addresses, and do away with all centralised user account management. We give every user the complete freedom to present themselves to other users as they wish, and only allow people in who we trust to play nicely. A nice side-effect of this approach is that the system can be run with only very limited day-to-day administration.

The requirements are:

  • only free software in a self-hosted environment;
  • usable for non-expert users, so no terminal-based chat clients or IRC commands;
  • data minimisation: we do not want to know anything about our users we don’t need;
  • completely shut off from anyone who is not on an authorized connection;
  • very low barrier of entry: you pick a name and you can start chatting.

We solve this by running an IRCd daemon on a server, together with a web IRC client that is dumbed down as far as possible, and that only connects locally. We use nginx as a reverse proxy for the web client, and we limit access to the client by using firewall-based IP safelisting.

I assume you have a regularly hardened Debian server on which you have root, and a domain name that you use only for this purpose, and that points to the server. In the examples, I use - change it to your own domain name. Also, unless otherwise noted, all commands are run as root.

Install software

Install the required software:

apt install nginx ircd-irc2 certbot python-certbot-nginx ufw

Configure IRCd

Most of the configuration work on IRCd is to close off as much functionality as possible, turning it into a boring, local-only chat server.

First, generate a password hash using ircd-mkpasswd:

ircd-mkpasswd -d -p YOURPASSWORD

Take note of the output: this is the password hash for the IRC operator user (admin).

If ircd-mkpasswd blocks, it probably is using /dev/random internally. Choose a two-character salt yourself (‘pi’) and run again with an explicit salt:

ircd-mkpasswd -d -spi -p YOURPASSWORD

Next, edit /etc/ircd/ircd.conf:

M%irc.localhost%%Debian ircd default configuration%%000A

A%This is the internal chat server%%%%





You can read about all these options and more in /usr/share/doc/ircd-irc2/ircd.conf.example.gz (don’t forget to use zless to read it).

Now, set a message of the day for your users. This is the message they will see if they enter the control channel by mistake. It should be aimed at getting non-expert users back on track. A helpful text might include “Are you running into trouble somewhere? Reload this page to get back to the login screen.”. Include an email address for technical questions as well.

Now, restart IRCd:

systemctl restart ircd-irc2.service

If you want, you can run irssi locally on the server to check whether all IRC features work as you expect.

Configure Kiwi IRC web client

First, grab the most recent stable package from the Kiwi IRC website. When in doubt, get the 64-bit Debian version (you are running a Debian system, are you not?). Next, install the package.

apt install ./kiwiirc_20.05.24.1-1_amd64.deb

Note that you need the ‘./’ part in the install command, to make it use the local package instead of looking in its repositories.

Next, change configuration in /etc/kiwiirc/config.conf to:

# 1 = Debug; 2 = Info; 3 = Warn;
logLevel = 3

# Enable the built in identd server (listens on port 113)
identd = false

# The name of this gateway as reported in WEBIRC to IRC servers
gateway_name = "webircgateway"

# A secret string used for generating client JWT tokens. Do not share this!
secret = ""

recaptcha_secret = ""
recaptcha_key = ""

# Default username / realname for IRC connections. If disabled it will use
# the values provided from the IRC client itself.
# %h will be replaced with the users hostname
# %i will be replaced with a hexed value of the users IP
username = "%i"
realname = "I am a webchat user"

# This hostname value will only be used when using a WEBIRC password
#hostname = "%h"

# The websocket / http server
bind = ""
port = 7778

# Serve static files from a web root folder.
# Optional, but handy for serving the Kiwi IRC client if no other webserver is available
enabled = true 
webroot = /usr/share/kiwiirc/


# Websites (hostnames) that are allowed to connect here
# No entries here will allow any website to connect.

# If using a reverse proxy, it must be whitelisted for the client
# hostnames to be read correctly. In CIDR format.
# The user IPs are read from the standard X-Forwarded-For HTTP header

# Connections will be sent to a random upstream
hostname = "localhost"
port = 6667
tls = false
# Connection timeout in seconds
timeout = 5
# Throttle the lines being written by X per second
throttle = 2
# Webirc password as set in the IRC server config
webirc = ""

# A public gateway to any IRC network
# If enabled, Kiwi IRC clients may connect to any IRC network (or a whitelisted
# network below) through the kiwiirc engine
enabled = false
timeout = 5
throttle = 2

# Whitelisted IRC networks while in public gateway mode
# If any networks are in this list then connections can only be made to these

Next, create a user interface configuration at /etc/kiwiirc/client.json:

    "windowTitle": "Example internal chat",
    "startupScreen": "welcome",
    "kiwiServer": "/webirc/kiwiirc/",
    "restricted": true,
    "theme": "default",
    "themes": [
        { "name": "Default", "url": "static/themes/default" },
        { "name": "Dark", "url": "static/themes/dark" },
        { "name": "Coffee", "url": "static/themes/coffee" },
        { "name": "GrayFox", "url": "static/themes/grayfox" },
        { "name": "Nightswatch", "url": "static/themes/nightswatch" },
        { "name": "Osprey", "url": "static/themes/osprey" },
        { "name": "Radioactive", "url": "static/themes/radioactive" },
        { "name": "Sky", "url": "static/themes/sky" }
    "startupOptions" : {
        "server": "localhost",
        "port": 6667,
        "tls": false,
        "direct": false,
        "channel": "#mainchatroom",
        "nick": "",
        "greetingText": "Example internal chat",
        "infoBackground": "",
        "infoContent": "<h3>Great to see you!</h3><p>This is an internal chat service. It is only reachable from within the organisation.</p><p><i>Admin: Jane Doe (</i></p>",
        "showPassword": false,
        "showChannel": false,
        "buttonText": "Join the chat"

Finally, restart the Kiwi IRC daemon with service kiwiirc restart.

The Kiwi IRC daemon is running on localhost only. This is a safer option, as we wouldn’t want to expose the chat directly to the outside world. In order to check that it’s working, establish an SSH tunnel from your workstation. Using OpenSSH, you type:

ssh -L 1234:localhost:7778

Now, point your browser to http://localhost:1234. Your chat service should load, and you should be able to connect using any username. Do not proceed until this works.

Configure nginx

We’ll use nginx as a reverse proxy in front of the Kiwi IRC listener.

Use the Certbot installation instructions for Nginx to acquire a Let’s Encrypt certificate for Then, create a virtual host in /etc/nginx/sites-available/

server {
    listen 443 ssl;
    listen [::]:443 ssl;


    ssl_certificate /etc/letsencrypt/live/;
    ssl_certificate_key /etc/letsencrypt/live/;

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

    location / {
        index index.html;
        root /usr/share/kiwiirc/;

    location /webirc/ {
        # Forward incoming requests to local webircgateway socket
        # Set http version and headers
        proxy_http_version 1.1;
        # Add X-Forwarded-* headers
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Forwarded-For    $remote_addr;

        # Allow upgrades to websockets
        proxy_set_header Upgrade     $http_upgrade;
        proxy_set_header Connection  "upgrade";


    location /files/ {
        # Forward incoming requests to local fileupload instance
        # Disable request and response buffering
        proxy_request_buffering  off;
        proxy_buffering          off;
        proxy_http_version       1.1;

        # Add X-Forwarded-* headers
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Forwarded-For    $remote_addr;

        # Allow upgrades to websockets
        proxy_set_header Upgrade     $http_upgrade;
        proxy_set_header Connection  "upgrade";
        client_max_body_size         0;
    }	 }

Now, enable this virtual host:

cd /etc/nginx/sites-enabled
ln -s ../sites-available/

Reload nginx for the changes to take effect:

service nginx reload

The chat service should now be world-reachable at Make sure that this works before proceeding.

Of course, we don’t want our chat service to be open to everyone! Let’s close it up using a firewall, ufw.

Implement safelist on firewall

I assume that you have a list of all IP addresses that should have access to the chat service. Note that anyone who connects to the internet through any of these IP addresses can use your chat service and impersonate any user.

Activate the firewall with:

ufw allow ssh
ufw allow http
ufw deny out smtp
ufw deny out 6660:6669,6697,7000/tcp
ufw enable

The second rule allows connections over plain http, for the Let’s Encrypt certificate validation. The third and fourth rule are there to limit the damage in case of a hack. If an attacker were to penetrate the system, they would still be prevented from spamming email or IRC.

Next, for each authorized IP address, allow it access to the https port:

ufw allow from <authorized IP address> to any port https comment <reference for this IP address, e.g location name>

Test the configuration: every authorized IP address should be able to access the server at, but no other IP address should be able to.

That’s it!

We’re done! Enjoy your internal chat service.