Skip to content

IRC Bouncer Setup With Soju, Gamja, Caddy and Docker

irc docker caddy

Around a week ago, I decided to take a look at IRC again, and setup an IRC bouncer. After some search, I settled on going with soju as my IRC bouncer, and gamja as a nice web frontend. Since I use Caddy and Docker in my server, but neither provides a Dockerfile, I thought sharing my setup with the world could be useful.

In this tutorial, we will be building our own soju and Caddy Docker images. Then, configure soju with Caddy to reverse proxy the IRC traffic with TLS encryption, as well as a WebSocket for gamja. I assume that you have knowledge of Docker and Caddy.

The soju Dockerfile

The Dockerfile for soju is quite simple, and is based on Alpine1. It just installs some dependencies since soju with SQLite requires a CGO build - Go and C. Then it clones the repository, builds the binaries - yes, there are three! -, and copies them to a base Alpine image.

FROM golang:1.21-alpine3.18


RUN apk update && apk add --no-cache git make build-base

RUN git clone && \
  cd soju && \
  git checkout $SOJU_VERSION && \
  make soju

FROM alpine:3.18

COPY --from=0 /soju/soju /usr/bin/soju
COPY --from=0 /soju/sojudb /usr/bin/sojudb
COPY --from=0 /soju/sojuctl /usr/bin/sojuctl

RUN mkdir -p /run/soju/

VOLUME /var/lib/soju/
VOLUME /etc/soju/

CMD ["soju"]

The Caddy Dockerfile

Now, Caddy. Caddy is interesting. Caddy is not able to reverse proxy non-HTTP traffic by default. But there’s a nifty caddy-l4 experimental plugin that opens the Caddy world to pure TCP and UDP handling. We’re going to use it2. But there’s a slight issue: it does not support the Caddyfile format.

Since I don’t want to convert my quite large Caddyfile into Caddy’s JSON format, I will be using a lazy strategy based on Andrew’s post. In this strategy, we will have an entrypoint script that converts a Caddyfile into a JSON configuration file, and mashes it together with a layer4.json file that contains the caddy-l4 configuration.

Here’s the Dockerfile:

FROM golang:1.21-bookworm


# Builds Caddy with all the plugins you need. I fixed caddy-l4 into a
# specific commit to avoid breaking changes without noticing. Add other
# plugins you might need.
RUN go install; \
  xcaddy build $CADDY_VERSION \
    --with; \
  mv caddy /usr/bin/caddy

FROM alpine:3.19

RUN apk add --no-cache ca-certificates mailcap jq

COPY --from=0 /usr/bin/caddy /usr/bin/caddy

RUN chmod +x /usr/bin/caddy; \
	caddy version

# See for details

VOLUME /config
VOLUME /data


COPY ./ /srv/

CMD ["/srv/"]

And now the script, which should be in the same directory as the Dockerfile:

#!/usr/bin/env sh

set -eou pipefail

echo "Removing existing caddy.json file"
rm -f ./caddy.json

echo "Formatting Caddyfile"
caddy fmt --overwrite /etc/caddy/Caddyfile

echo "Generating caddy.json file"
caddy adapt --config /etc/caddy/Caddyfile --pretty --validate > caddy.json

if [ -f /etc/caddy/layer4.json ]; then
	echo "Reading /etc/caddy/layer4.json"
	layer4_content=$(cat /etc/caddy/layer4.json)

	echo "Embedding layer4 content into caddy.json"
	jq --argjson layer4 "$layer4_content" '.apps.layer4 = $layer4' ./caddy.json > ./temp.json
	mv ./temp.json ./caddy.json

echo "Starting Caddy"
caddy run --config ./caddy.json

Notice that it checks for the layer4.json file. My goal was to make this image reusable in case I want to use for other purposes that do not have the layer 4 proxying requirements.

Building gamja

gamja does not provide already built files. However, building them is quite easy, but you need Node.js for this. Just clone the repository, install the dependencies, and build the minified version of the frontend. The built files are just static HTML, CSS and JS files. Copy them into a directory that you will attach to the Caddy container to serve.

git clone
cd gamja
npm install --include=dev
npm run build
cp -R build /www/path/gamja

The soju Configuration

Create a config.ini file for Soju configuration and add the following. Don’t forget to replace the example names and hostnames by your server name, and your address. This configuration stores the database and messages on the default directories. These will be mounted as Docker volumes.

title My Server Name

db sqlite3 /var/lib/soju/main.db
message-store fs /var/lib/soju/logs/

listen unix+admin://
listen irc+insecure://
listen ws+insecure://

This configuration listens on quite a few ports. Let’s quickly break it down:

  • unix+admin:// is the socket used by sojuctl. This will only be used for some administrative commands and must not be exposed outside the container.
  • irc+insecure:// is the IRC protocol itself. This will be reverse proxied with Caddy in order to add TLS on top.
  • ws+insecure:// is the WebSocket that will be reverse proxied by Caddy and used by gamja. Caddy will also take care of the TLS of this one.

The Caddy Configuration

Now, the fun Caddy configuration part. In your Caddyfile, add the following in order to serve the static gamja files and reverse proxy the WebSocket /socket path to the soju container on port 3030. {
	root * /www/gamja
	reverse_proxy /socket soju:3030

And now create a layer4.json file with the configuration to proxy the IRC traffic. Notice that we’re listening on port 6697 and proxying to the port 6667 of the soju container. 6697 is the TLS-encrypted IRC “standard” port. Don’t forget to change the value of default_sni to the correct address! You can read more about this specific configuration here.

  "servers": {
    "soju": {
      "listen": [
      "routes": [
          "handle": [
              "handler": "tls",
              "connection_policies": [
                  "alpn": [
                  "default_sni": ""
              "handler": "proxy",
              "upstreams": [
                  "dial": [

The Docker Compose

Now, the last piece of the puzzle is the Docker compose file combining all this things. My compose.yaml directly references the directories in which the Dockerfiles for both soju and Caddy are located, such that I can just build them directly. Don’t forget to change the paths to the correct paths in your system.

version: '3'
    restart: always
    container_name: soju
    build: /path/to/soju/dockerfile
      - /path/to/config.ini:/etc/soju/config:ro
      - soju-data:/var/lib/soju/
    restart: always
    container_name: caddy
    build: /path/to/caddy/dockerfile
      - "80:80"
      - "443:443"
      - "6697:6697"
      - /path/to/Caddyfile:/etc/caddy/Caddyfile
      - /path/to/layer4.json:/etc/caddy/layer4.json
      - /path/to/gamja:/www/gamja
      - caddy-data:/data
      - caddy-config:/config

Finally, start everything:

docker compose build
docker compose up -d

At this point, you should have gamja being served on and be able to access your IRC bouncer via the TLS encrypted socket at ircs:// Don’t forget to change them to the correct address where you’re actually serving everything.

Now, you just need a user, which you can create with a quick command inside the soju container:

docker compose exec -it soju sojuctl user create -admin \
  -username <username> \
  -password <password>

This will create an admin user which can talk to BouncerServ to create other users if needed. At this point, you can navigate to where you’re serving gamja and login with the credentials from the user you just created. You can also just use any other client to connect to this bouncer at ircs://

Note that for clients that do not support some IRCv3 functionality, or for clients that do not support soju’s extension, you’ll need to login with a username suffix to get proper chat playback. Read soju’s manual page for more information.

I hope this tutorial was helpful for you. This was quite an adventure for me, and it seems to be working!

  1. We’re building soju with an older version of Alpine because there are incompatibilities between one of its dependencies and the newer version of the musl compiler. A patch has already been merged, and will go with the next version of soju. ↩︎

  2. Another option would be to leverage the fact that the Caddy provisioned TLS certificates are present on disk. Then, share a Docker volume between Caddy’s and soju’s container. Then configure soju’s TLS option to use the Caddy certificates. With this strategy, you’d need to make sure soju would refresh the configuration when Caddy updates the certificates. This could potentially be done with caddy-events-exec plugin. ↩︎