IRC Bouncers

How to set up a ZNC IRC bouncer and connect with a Doom Emacs client.

How to Set Up an IRC bouncer with Doom Emacs

Published: Sat, 13 Sep 2025 12:26:41-4:00
IRC | ZNC | Nginx | Docker

IRC is a great chat protocol, but it has some annoying limitations when compared to other modern chat protocols. First and foremost, it lacks the ability to see messages that are sent on servers when you are not connected to a server via a client. Messages are sent to your client when you are connected, but there is no obvious way to log messages that are sent while you are offline. This means that when you log into a server you might come into the middle of an existing conversation with no context, or if you send someone a direct message and then log off, they have no way to send you a response until you log back on to the server again. Luckily there is an excellent, if not entirely straightforward or ergonomic, solution to this. The IRC bouncer.

What is an IRC Bouncer?

In simple terms, an IRC bouncer is an application that is able to connect to one or more IRC servers, such as Libera Chat, and sending and receiving messages via the IRC protocol in a similar way to a regular IRC client. There are some key distinctions though. An IRC bouncer is designed to be connected to an IRC server continuously and is able to log messages that are sent when we aren’t connected with a client. Another distinction is that when we connect with an IRC client, our username, or nickname, is registered with the server. This means that our nickname will be unavailable if we try to connect again with another client. The workaround to this is that clients provide the functionality to define aliases or backup user names that look very similar to our normal user name, but are slightly different. For example, if my normal user name is Naokotani, I could log in as Naokotani~ from a second client and people will most likely be able to deduce who I am. Still, people don’t know for sure that it is you, and it is a bit jarring to many of us that are accustomed other modern chat software where logging in with the same username from different clients is common place.

IRC bouncers pose a solution to this problem. Instead of logging into a server separately from different clients, the IRC bouncer logs into the server once with our nickname and stays logged in. The bouncer then acts as a proxy that we can connect to multiple times from different clients and, as far as the server is concerned, we have only logged in once.

IRC bouncers also solve the problem of missed messages. They are able to log messages that you miss while you are not connected with a client because they are connected all the time. The bouncer can log messages and use a “playback” to show you the messages that were sent while you were not connected. This is similar to other chat software that will use a horizontal rule to separate the messages that you have “seen” from those that were sent while you were away.

My Setup

As is customary with open protocols like IRC, there are many ways to implement this functionality. In my case I opted to host a ZNC bouncer on a VPS, in my case I used Digital Ocean, but any provider should suffice, run it in Docker, reverse proxy it with Nginx and then connect to it from Doom Emacs using the Emacs Circe client, the default IRC client for Doom Emacs.

These selections are somewhat arbitrary, but determined by infrastructure that I had set up previously for other projects, and there are many viable alternatives to these. Soju is a popular alternative to ZNC that you might want to check out. I am using Nginx as the reverse proxy for my setup and, while it is very capable of performing this task well, Nginx can be a little bit arcane and hard to setup. Caddy is another popular choice for a reverse proxy. It is generally easier to setup and has built-in automatic SSL. If you don’t like Docker for whatever reason, you can always use Podman, or of course you can just run the bouncer directly on your host machine. As for clients, you could also use ERC, a CLI app like Weechat, or a GUI client like Polari. I could digress and talk about all of these, but there are enough moving parts here already that it’s best we get to it.

Running ZNC

The first step is to get ZNC itself running on your machine. You can do this in two simple commands. First run:

docker run -it -v znc-cfg:/znc-data znc --makeconf

This will pull the official ZNC image if it’s not on your machine, create a persistent volume for the ZNC configuration data called znc-cfg, run it with an interactive terminal, the -it part, and then run ZNC with the --makeconf flag which, as the name suggestions, will setup an initial configuration file.

When you do this you will be prompted to make some initial configuration entries. You should get prompts that look like this.

[ ** ] -- Global settings --
[ ** ]
[ ?? ] Listen on port (1025 to 65534): 12345
[ ?? ] Listen using SSL (yes/no) [no]:
[ ?? ] Listen using both IPv4 and IPv6 (yes/no) [yes]:
[ .. ] Verifying the listener...
[ ** ] Unable to locate pem file: [/znc-data/znc.pem], creating it
[ .. ] Writing Pem file [/znc-data/znc.pem]...
[ ** ] Enabled global modules [corecaps, saslplainauth, webadmin]
[ ** ]
[ ** ] -- Admin user settings --
[ ** ]
[ ?? ] Username (alphanumeric): User
[ ?? ] Enter password:
[ ?? ] Confirm password:
[ ?? ] Nick [User]:
[ ?? ] Alternate nick [User_]:
[ ?? ] Ident [User]:
[ ?? ] Real name (optional):
[ ?? ] Bind host (optional):
[ ** ] Enabled user modules [chansaver, controlpanel]
[ ** ]
[ ?? ] Set up a network? (yes/no) [yes]:
[ ** ]
[ ** ] -- Network settings --
[ ** ]
[ ?? ] Name [libera]:
[ ?? ] Server host [irc.libera.chat]:
[ ?? ] Server uses SSL? (yes/no) [yes]: yes
[ ?? ] Server port (1 to 65535) [6697]:
[ ?? ] Server password (probably empty):
[ ?? ] Initial channels:
[ ** ] Enabled network modules [simple_away]

First, it’s important that Listen on port is set to the same port that you exposed in your Docker command. The Docker command only binds the container’s port to your hosts port, but the Listen on port is what actually tells ZNC to listen on that port inside the container. Additionally, we want to set Listen using SSL to no because ZNC itself is not going to be handling SSL in our setup, Nginx will do that. ZNC should only be exposed internally on the server, so encryption is not necessary. Finally we need to create an admin user that we will use later to connect to the ZNC web front end. You can set the username and password to whatever you wish, but keep in mind that this will be publicly accessible on the web, so probably avoid something like user: admin pass: so-secret. It is convenient to setup the network settings now because it will be mostly applying default settings, but you can also set it up in the web front end later if you wish.

Once this is setup, you can run this command:

docker run -d \
  --restart unless-stopped \
  --name znc-server \
  -p 12345:12345 \
  -v znc-cfg:/znc-data \
  znc

This command will run ZNC in a Docker container. --restart unless-stopped will make it so the container will automatically restart if the process ends, for example if your server restarts. --name znc-server will name the container. If you don’t do this it will still work, but Docker will automatically assign a silly name like wonky_wombat or something. Naming the container makes things easier if you want to make changes later. -p 12345:12345 will expose the port 12345 inside the container to the host outside the container. This makes ZNC accessible from outside the container. The ports don’t need to be the same, and you can choose whichever port you would like in the available range of non-privileged ports: 1024-49151. The main thing is if you have another process running on a port you cannot reuse it, so you have to make sure the port is available. If there is a conflict, you can run lsof -i :12345 to check what is running on port 12345 on your host, or more simply just choose a different port. Finally, -v znc-cfg:/znc-data means the container will use the volume we put our configuration file in earlier. If we don’t do this, we will lose our configuration data when the container restarts.

Setting Up Nginx and SSL

Now that we have ZNC up and running, we need some way to access our ZNC process from outside our server. You can expose ZNC directly to the public internet, but it is common practice to route public traffic through a reverse proxy like Nginx or Caddy and have that handle directing the TCP and HTTP requests to your ZNC server. This also means that Nginx or another reverse proxy will handle SSL instead of ZNC, which is why we said no to SSL encryption in the initial ZNC configuration.

Our first step will be to ensure that we have a FQDN (like irc.mysever.com) pointing to our server. It is possible, but cumbersome to enable SSL without a FQDN, and in most cases not worth the effort. The ZNC wiki gives two options for this. You can either use a sub domain, for example irc.myerver.com, or you can use a route on an existing domain, such as myserver.com/irc. We are going to use the sub domain method in our setup. Once the domain is pointed to our server’s IP and the ZNC server is running, we can make a location block like this in our nginx.conf file:

server {
    server_name irc.myserver.com;
        location / {
            proxy_pass http://[::1]:12345/;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
}

This means that any http requests that Nginx gets to irc.myserver.com will be directed to port 12345 on our host machine where ZNC is listening because we added the Docker -p 12345:12345 flag and we input for Listen on port when you ran znc --makeconf earlier. It will also forward the headers from the original request on to port 12345. Once we have reloaded Nginx and this takes effect, requests should already be routed correctly, but we still need to set up SSL before we can connect the ZNC via HTTP.

While Nginx does not have built-in SSL like Caddy, it is fairly easy to set up. Certbot will setup certificates for us and update our nginx.conf file to point to the correct certificates.

At this point, you should be able to access the ZNC web front end, and you can input the username and password you input earlier to access the configuration for your ZNC server, but there is something else we need to do with Nginx. The location block we setup earlier listens specifically for HTTP and HTTPS requests on ports 80 and 443 respectively, which is exactly what the ZNC web front end uses, but the IRC protocol itself uses TCP protocol. Nginx needs a way to appropriately proxy TCP requests to ZNC. Furthermore, since port 443 is the standard port for HTTPS requests, we need to configure Nginx to listen for TCP requests on a different port. To do this, we need to create a stream block (you can find the documentation for this here) similar to this:

stream {
    upstream znc {
        server [::1]:12345;
    }

    server {
        listen 54321 ssl;
        proxy_pass znc;

        ssl_certificate /etc/letsencrypt/live/irc.domain.dev/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/irc.domain.dev/privkey.pem;
    }
}

We need to create a stream block that defines a server and upstream block. The server defines the port Nginx will listen on with the listen directive, which we set to port 54321 in this example. The listen directive also specifies that we are listening for SSL connections. We can use the same SSL certs that we created for the location block for the web front end earlier by simply copying the directives from our HTTP location block. The proxy_pass directive passes that request on to the named upstream znc block defined above, which defines the internal port that Nginx should pass the TCP stream on to, 12345. The port defined here must match the port that we bound the Docker container to, which in our case was 12345. You can double check this by running docker ps and then under PORTS you should see something like 0.0.0.0:12345->12345/tcp, [::]:12345->12345/tcp, which tells us that both IPv4 and IPv6 port 12345 inside the container is bound to the same port on the host. I used different ports, 54321 and 12345 here to make the distinction between the two, but they can be the same number. Another way to think of this, is that it’s very similar to Docker -p 12345:12345; its binding TCP requests outside our host to the port inside our host.

You might need to create a firewall rule to ensure that your firewall is not blocking port 54321, or which ever port is defined by your listen directive. For example if you are using ufw you can use the command ufw allow 54321/tcp with root privileges which will allow Nginx to receive TCP requests on port 54321.

At this point we should be done with the work on the server itself and it’s time to do some work on configuring ZNC to talk to an IRC server like Libera.

Configuring ZNC

Now that everything is set up on the server, we should be able to navigate to https://irc.myserver.com on the public internet to access our ZNC configuration to do the final bits of work needed to connect to the Libera server.

First and foremost, navigate to the Global Settings tab and scroll down to skin select Forest. This is by far and away the most important step so don’t skip it!

Next, we need to make sure that ZNC is correctly configured to connect to Libera. Select the Your Settings tab and scroll down to the Networks section. If we configured it during the znc --makeconf step, we should see Libera there already. We can click Edit to get further settings. This is where we will input all of the important information we need to log into Libera. Ensure that SSL is ticked and that the port is set correctly to 6697, the default port for libera.chat.irc SASL connections. Finally, scroll down to the Modules section and tick the sasl module. Once everything is set, we can select save and return near the bottom of the page.

Once we have saved these settings we should see Network Modules (libera) section with a SASL hyperlink. We need to click that and input our Libera Nickname and password. This allows ZNC to connect to Libera via SASL. At this point ZNC should be able to connect to Libera and all that is left is to connect to your ZNC server via Doom Emacs, or another client of your choosing.

Connecting with Doom Emacs

On Doom Emacs, to add custom configuration we can use M x doom/open-private-config to open up our personal configuration file. In order to connect to our server, we need to add something like the following:

(after! circe
  (set-irc-server! "znc/libera"
    (let* ((auth (car (auth-source-search
                       :host "irc.myserver.com"
                       :port 54321
                       :user "StampChad"
                       :require '(:user :secret))))
           (secret (plist-get auth :secret)))
      `(:host "irc.myserver.com"
        :port 54321
        :tls t
        :nick "StampChad"
        :pass ,(if (functionp secret) (funcall secret) secret)
        :channels ("#stamp-collecting")))))

This will connect to our ZNC server at irc.myserver.com port 54321. You might notice that instead of our plain text password, we are getting our password from our authinfo.gpg file. The process of using an authinfo.gpg file is explained in this excellent blog post. Simply put, you need to ensure you have a gpg key generated by running gpg --full-generate-key and following the prompts and then simply create a file called ~/.authinfo.gpg and input your information in the following format:

machine irc.myserver.com login StampChad password StampChad/libera:s0-s3cr3t port 54321

In the above example, the let* binds the return value of auth-source-serach to the secret variable. Instead of passing our plain text password to :pass, we wrap our Circe config in the let* and then we pass it (if (functionp secret) (funcall secret) secret). This checks whether auth-source-search returned a function or a string. If it returned a function, (funcall secret) calls secret as a function and returns our password, otherwise it just returns the value of secret, which should be a our password as a string.

Wrap Up

And that’s it! We should now be able to run M-x =irc, the Doom Emacs command to run Circe, and we are ready to chat. ZNC will stay connected and update us about chat that we have missed in the channels we are connected to. We can also connect with as many clients we want. There are a lot of ZNC modules to explore and this is really just scratching the service, but getting a basic set up going is a big first step.

System Crafters Web Ring

Messing around with computers and coding since I was 8. Now getting paid to do what I love.