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.
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.
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!
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
}
}
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
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.
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!!!!
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
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…
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!
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
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…