hosting soju + gamja with docker for deranged IRC reasons

kat, 2025 april 19

hey i did this in about a day because i felt like it. here’s a little guide on how i did all of this in docker without existing images and got myself a nice little bouncer and web client for IRC chatting in 2025 like a sicko.

prerequisites

i’ll assume you know basic docker stuff for this because that’s kind of necessary.

also this is assuming you run caddy bare metal outside of docker. because that’s what i do. this is an opinionated guide ok. anyway the only difference here is that the caddy container in the stack is going to be serving everything on HTTP and then you have to use your bare metal caddy instance to reverse proxy the container caddy instance. convoluted, yes. abnormal, yes. i have nothing else to say this is just how i do things

anyway go create your base directory. this should probably be in /var/www but i put mine in my home directory for stupid reasons.

right off the bat, you’ll want to make some folders in that base directory:

mkdir soju gamja caddy

these are the services you’ll be deploying. or, well, it’s just soju and caddy — gamja is only going to be built for production with docker, and caddy will serve those output files.

gamja

first, get the latest release of gamja from its git repository releases page. as of writing, it’s v1.0.0-beta.11. extract that into the gamja directory.

basically we’re gonna use docker to do the sicko node shit for us without having to install node or npm or whatever the fuck. i don’t want that on my system so i made a dockerfile instead. i hate javascript that much (real) (true)

anyway, create a dockerfile in the gamja directory and copy/paste the following:

FROM node:23-alpine AS build

WORKDIR /gamja

COPY package.json package.json
COPY package-lock.json package-lock.json

RUN npm install --include=dev

COPY . .

RUN npm run build

FROM scratch
COPY --from=build /gamja/dist ./dist

all this will do is work in the gamja directory, install dependencies, then build the production-ready gamja files into a new directory called dist. so to get all of that, here’s the command you run:

docker build gamja --output=gamja

and after the image builds and runs, you should have a new directory within the gamja folder — dist is what will be served by the caddy service to get your web client!

configuring gamja

gamja can be configured with a file in the dist directory called config.json. it’s pretty flexible so i’ll link the docs page on it but here’s mine for reference (replace example domain):

{
    "server": {
        "url": "wss://example.com/socket",
        "auth": "mandatory",
        "autoconnect": false,
        "ping": 60
    }
}

caddy

this one’s pretty simple. in your caddy directory, create a Caddyfile (capital C is important i think?) and input the following contents, replacing example.com with your domain:

http://example.com {
    root * /var/www/gamja
    file_server
}

yeah it’s literally just that. disappointing i know. when we run the caddy container that’ll handle most things so yeah

soju

the big one! ok so here’s the dockerfile you know the drill by now:

FROM golang:1.23-alpine AS build

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

WORKDIR /soju

ENV SOJUTAG=v0.9.0

RUN git clone https://codeberg.org/emersion/soju.git . && \
    git checkout $SOJUTAG && \
    make soju

FROM alpine:latest

RUN mkdir -p /run/soju/

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

CMD ["/usr/bin/soju"]

this guide was written when v0.9.0 was the latest version of soju, so if you’re reading and there’s newer versions out, change that SOJUTAG variable to the latest version number.

all this dockerfile does is build soju from source with its makefile, then move the three outputted binaries that soju requires to /usr/bin. the image runs soju but the other binaries are accessible via docker exec.

configuring soju

soju is configured with a config.ini file in the soju directory. this one took me a while to understand but basically paste something like this in that file, replacing the example domain with your domain:

title bouncer_title
hostname example.com

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

listen unix+admin://
listen irc+insecure://0.0.0.0:6667
listen ws+insecure://0.0.0.0:5348
http-origin example.com

you can put anything in place of bouncer_title, it’s just the name of your bouncer i guess.

your IRC insecure port will be 6667 and your websocket port (and what will be reverse proxied by the bare metal caddy build later) is 5348. note these down!!!!

bare metal caddy TLS with caddy-l4

to get caddy to proxy TLS/TCP traffic, you need to build your bare metal caddy server with caddy-l4. this provides, well, TCP/UDP/TLS/etc functionality for caddy! it’s pretty neat. unfortunately though it’s also kind of hard to understand. i’ll… try to help lol?

so first, the easiest way to build a new caddy binary is with xcaddy. so install that (caddy has docs for that), and then run the following:

xcaddy build --with github.com/mholt/caddy-l4

and it should spit out a new caddy binary in your current working directory. run this to copy it over your existing binary (be sure to reload caddy after if you have any existing caddy stuff deployed):

sudo cp caddy /usr/bin/caddy

alright it’s config time. oh god this took me fucking forever to understand.

at the top of your bare metal caddyfile (default is at /etc/caddy/Caddyfile), paste something like this:

{
    layer4 {
        0.0.0.0:6697 {
            route {
                tls {
                    connection_policy {
                        alpn irc http/1.1 http/1.0
                        default_sni example.com
                    }
                }
                proxy {
                    upstream 192.168.1.123:6667
                }
            }
        }
    }
}

replace 192.168.1.123 with your local server’s IPv4 address (or whatever it’s listening on idk how this works for a VPS), and example.com with your domain.

basically all this does is reverse proxy the insecure IRC port 6667 to the secure IRC port at 6697 with TLS! plus support for IRC and HTTP so it can communicate over those.

after that, reverse proxy your domain as follows, with websocket support:

https://example.com {
    reverse_proxy 192.168.1.123:2856

    @websockets {
        header Connection *Upgrade*
        header Upgrade websocket
    }

    handle /socket {
        reverse_proxy @websockets 192.168.1.123:5348
    }
}

the 2856 port is what the caddy container will be listening on instead of port 80 by the way. can you tell i just key-smashed most of these IPs by the way because i did do that

compose file

it’s simple i swear:

services:
  soju:
    container_name: soju
    build:
      context: ./soju
      dockerfile: Dockerfile
    volumes:
      - ./soju/config.ini:/etc/soju/config:ro
      - ./soju/soju-data:/var/lib/soju
    ports:
      - "6667:6667"
      - "5348:5348"
    restart: always

  caddy:
    container_name: caddy
    image: docker.io/caddy
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
      - ./caddy/data/caddy-data:/data
      - ./caddy/data/caddy-config:/config
      - ./gamja/dist:/var/www/gamja
    ports:
      - "2856:80"
    restart: always

most of this is self explanatory i hope. the data directories for soju and caddy will be created when the containers are created. speaking of which…

FIRST RUN YAY

let’s go!!!!!!!!

docker compose up -d

your soju container should build, and if everything went right, your client should be available at your domain. but first, you have to create a user!

soju admin commands

for quick reference, here’s two soju commands that will be useful: creating normal & admin users!

admin users:

docker compose exec -it soju sojuctl user create -admin \
  -username USERNAME \
  -password PASSWORD

replace USERNAME and PASSWORD with the desired values. these can’t be changed from the client so uh save them!

and for normal users it’s the same, without the admin flag:

docker compose exec -it soju sojuctl user create \
  -username USERNAME \
  -password PASSWORD

replace the same values yada yada you get it

ending stuff

yeah i think you should be good now. this is a short and sweet one (haha sabrina carpenter reference go listen to taste) and i think a lot of it’s self explanatory but yeah does the job. from here you can connect to libera chat or something idk go wild

maybe next time i’ll host my own IRC server. we shall see…