Setting up a tilde

This is a living document, describing my progress on setting up my own tilde/pubnix server. Keep in mind that this is very much a Work-In-Progress(tm).

So, I decided I wanted to host my own pubnix/tilde (I still don’t know what to call it LMAO). Basically, it’s gonna be a server where members can log in via SSH to collaborate on code, experiment with Linux and play around on the CLI.

I described the main idea in my previous post, so go read that if you’re interested.

Creating an account

After I set up the root account of my Debian server and logged in, I added my own account, and gave myself root privileges:

adduser robin
usermod -aG members robin # group that all members will be in
usermod -aG sudo robin

I also turned off root login over SSH in /etc/ssh/sshd_config:

PermitRootLogin no

And finally I set the timezone and hostname:

timedatectl set-timezone Europe/Amsterdam
hostnamectl set-hostname

Welcome messages

I wanted a nice welcome message to be printed when logging in, instead of the garbage that was printed before. I started out by emptying /etc/motd, and then disabling all scripts in /etc/update-motd.d.

I added a few new scripts in /etc/update-motd.d. The first two digits specify the order that the files get executed in. I added two files:

sudoedit /etc/update-motd.d/05-welcome
sudo chmod +x /etc/update-motd.d/05-welcome
sudoedit /etc/update-motd.d/10-currently-online
sudo chmod +x /etc/update-motd.d/10-currently-online

echo "Welcome to {du}punkto, a simple invite-only pubnix!"
echo "We hope you enjoy your stay here :)"

echo "These users are currently online:"
echo "you"
users | tr ' ' \\n | uniq


Member directories

I wanted members to have a nice default config when they joined, so I editted some files in /etc/skel:

sudoedit /etc/skel/.bashrc
# ~/.bashrc: executed by bash(1) for non-login shells.
# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
# for examples

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;

# Don't put duplicate lines or lines starting with space in the history.
# See bash(1) for more options

# Append to the history file, don't overwrite it
shopt -s histappend

# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)

# Check the window size after each command and, if necessary,
# update the values of LINES and COLUMNS.
shopt -s checkwinsize

# If set, the pattern "**" used in a pathname expansion context will
# match all files and zero or more directories and subdirectories.
#shopt -s globstar

# If the terminal used specifies colors, set a fancy prompt
case "$TERM" in
    xterm-color|*-256color) colors=yes;;

# Set variable identifying the chroot you work in (used in the prompt below)
if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
    debian_chroot=$(cat /etc/debian_chroot)

if [ "$colors" = yes ]; then
    PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '

# Color support in ls and other utils
if [ "$colors" = yes ]; then
    test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dir>
    alias ls='ls --color=auto'
    alias dir='dir --color=auto'
    alias grep='grep --color=auto'

# Colored GCC warnings and errors
#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:>

alias ll='ls -l'
alias la='ls -A'
alias l='ls -CF'
alias ..="cd .."
alias rm="rm -r" # Allow to remove directories
alias cp="cp -i" # Warn before overwriting existing files
alias mv="mv -i"

# Alias definitions.
# You may want to put all your additions into a separate file like
# ~/.bash_aliases, instead of adding them here directly.
# See /usr/share/doc/bash-doc/examples in the bash-doc package.

if [ -f ~/.bash_aliases ]; then
    . ~/.bash_aliases

# Enable programmable completion features (you don't need to enable
# this, if it's already enabled in /etc/bash.bashrc and /etc/profile
# sources /etc/bash.bashrc).
if ! shopt -oq posix; then
  if [ -f /usr/share/bash-completion/bash_completion ]; then
    . /usr/share/bash-completion/bash_completion
  elif [ -f /etc/bash_completion ]; then
    . /etc/bash_completion

Setting up Gemini hosting

I wanted members to be able to host Gemini sites, by putting .gmi files in the pub/gem directory.

After a bit of searching I found the molly-brown server, which does exactly what I want. One go install ... later, I copied it to /usr/local/bin.

It is very straight forward to setup. First generate a TLS cert (as root):

openssl req -x509 -out /etc/molly.crt -keyout /etc/molly.key \
  -newkey rsa:2048 -nodes -sha256 \
  -days 36500 \
  -subj '/CN=localhost' -extensions EXT -config <( \
   printf "[dn]\\n[req]\ndistinguished_name = dn\n[EXT]\,\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")

Then edit the /etc/molly.conf:

Port = 1965
Hostname = ""
DocBase = "/var/gemini/"
HomeDocBase = "users"
CertPath = "/etc/molly.cert"
KeyPath = "/etc/molly.key"

DirectoryListing = true
DirectorySort = "Time"
DirectorySubdirsFirst = true

I also created a user for it:

adduser --system --group molly --no-create-home --shell /usr/sbin/nologin --comment "Molly gemini server" molly
sudo chown molly:molly /etc/molly.*

However, I don’t want members to have to edit files in /var/gemini/users/username, I want them to have pub/gem. Luckily ChatGPT came up with this wonderful script that symlinks ~/pub/gem to the corresponding directory in /var/gemini:


# Define the base directory for repositories

# Loop through all the user directories in the 'members' group
for username in $(getent group members | cut -d: -f4 | tr ',' ' '); do
  # Create the repository directory with the user's permissions
  if [ ! -d "$web_dir" ]; then
    mkdir -m 0755 -p "$web_dir"

  # Ensure that the directory is owned by the user
  chown $username:$username "$web_dir"

  # Create a symbolic link to the user's home directory
  if [ ! -d "/home/$username/pub" ]; then
    mkdir -p "/home/$username/pub" 

  if [ ! -h "/home/$username/pub/gem" ]; then
    ln -s "$web_dir" "/home/$username/pub/gem"

  echo "Created directory and symlink for $username."

echo "Symlink creation completed."

The only thing left to do, is to setup a systemd unit and start the server. The molly-brown repo already provided a service file, which I gratefully copied:

Description=Molly Brown gemini server

ExecStart=/usr/local/bin/molly-brown -c /etc/molly.conf


I put it in /etc/systemd/system/molly-brown.service. Then simply run: systemctl enable molly-brown.service --now (as root), and kadabing kadaboom. A gemini server.


I also wanted normal webhosting, which would work the same as Gemini hosting, but uses the pub/web directory, instead of pub/gem. For that I chose nginx, which I installed with APT.

sudo apt install nginx

I added this in /etc/nginx/conf.d/01-userdirs.conf:

server {

        listen 80 default_server;
        listen [::]:80 default_server;

        # SSL configuration
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;

        root /var/www;

        index index.html index.htm;  

        fancyindex on;
	      fancyindex_header /header.html;
        fancyindex_footer /footer.html;
	      fancyindex_ignore "header.html" "footer.html";
	      fancyindex_time_format "%Y-%m-%d";

        try_files $uri $uri.html $uri.htm $uri/ =404; 

        location ~ (README|\.md)$ {
		        default_type text/plain;
		        charset utf-8;

        # Special config for me :)
        location /~robin {
		        fancyindex_header /~robin/header.html;
		        try_files $uri $uri.html $uri/ =404;

Again, I used a fancy script to symlink the directories:


# Define the base directory for repositories

# Loop through all the user directories in the 'members' group
for username in $(getent group members | cut -d: -f4 | tr ',' ' '); do
  # Create the repository directory with the user's permissions

  if [ ! -d "$web_dir" ]; then
    mkdir -m 0755 -p "$web_dir"

  # Ensure that the directory is owned by the user
  chown $username:$username "$web_dir"

  # Create a symbolic link to the user's home directory
  if [ ! -d "/home/$username/pub" ]; then
    mkdir -p "/home/$username/pub" 

  if [ ! -h "/home/$username/pub/web" ]; then
    ln -s "$web_dir" "/home/$username/pub/web"
  echo "Created directory and symlink for $username."

echo "Symlink creation completed."

Oh, and to setup SSL (as root):

apt install certbot python3-certbot-nginx
certbot certonly -d,,,,

If it says Nginx isn’t properly configured, that’s probably because it expects SSL to be there but it isn’t (yet). If so, just choose the “standalone server” option.

I also added this to my /etc/nginx/nginx.conf, which I generated using the Mozilla SSL Configuration Generator:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;  # about 40000 sessions
ssl_session_tickets off;

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

# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;

Git hosting

Setting up git hosting vas very straight forward after setting up Nginx. Just install cgit and edit the config file in /etc/cgitrc:


# Dumb clones
clone-url= http://$HTTP_HOST/$CGIT_REPO_URL


# Enable READMEs

# Prevent spam


root-desc=git hosting for {du}puntko members

robots=noindex, nofollow

I stole some CSS from and put it in /usr/share/cgit, along with a logo titled dupunkto.png.

@import "cgit.css";

html {
	background-color: white;

* {
	line-height: 1.35em;

@media (max-width: 480px) {
	html {
		font-size: 12px;

@media (min-width: 480px) {
	html {
		font-size: 13px;

@media (min-width: 786px) {
	html {
		font-size: 14px;

@media (max-width: 786px) {
	div#cgit table#header td.logo {
		display: none;

@media (min-width: 992px) {
	html {
		font-size: 15px;

@media (min-width: 1200px) {
	html {
		font-size: 16px;

@media (min-width: 1600px) {
	html {
		font-size: 18px;

div#cgit {
	/*font-family: monospace;*/
	font-size: 1em;

pre, div#cgit table.diff td, div#cgit table.ssdiff td {
	font-size: 1.1em;
	font-family: monospace;

code {
	font-family: inherit;

#summary code {
	font-family: monospace;

div#cgit div.commit-msg {
	font-family: monospace;

div#cgit, div#cgit, div#cgit .ls-mod, div#cgit, div#cgit {
	font-family: monospace;

div#cgit table.list td.commitgraph, div#cgit table.list td.logmsg, div#cgit table.list td.logsubject{
	font-family: monospace;

div#cgit .sha1 {
	font-family: monospace;

div#cgit table.ssdiff td {
	font-size: 14px;

.highlight pre {
	font-size: inherit;

div#cgit .commit-subject, div#cgit div#summary {
	max-width: 80ch;

div#cgit div.footer, div#cgit div.footer a {
	color: #aaa;

div#cgit table.list tr:nth-child(even) {
	background: inherit;

div#cgit table.list tr:hover {
	background: inherit;

div#cgit table.list tr.nohover-highlight:hover:nth-child(even) {
	background: inherit;

div#cgit table.commit-info th {
	color: #888;

div#cgit table.blob td.linenumbers a:target {
	color: white;
	background-color: black;

div#cgit table.list td {
	font-weight: normal;

div#cgit table.tabs td.form form, div#cgit table#header td.form {
	padding-bottom: 4px;

td.logo img {
	width: 100%;
	border-radius: 15px;

table.head, table.foot { width: 100%; }
td.head-rtitle, td.foot-os { text-align: right; }
td.head-vol { text-align: center; }
div.Pp { margin: 1ex 0ex; }
div.Nd, div.Bf, div.Op { display: inline; }
span.Pa, span.Ad { font-style: italic; }
span.Ms { font-weight: bold; }
dl.Bl-diag > dt { font-weight: bold; }
code.Nm, code.Fl, code.Cm, code.Ic, code.In, code.Fd, code.Fn,
code.Cd { font-weight: bold; font-family: inherit; }

table.Nm td:first-child { padding-right: 1ch; }
code.Fl { white-space: nowrap; }
span.RsT { font-style: italic; }
dl.Bl-tag:not(.Bl-compact) dt { margin-top: 1em; }
ul.Bl-bullet:not(.Bl-compact) li { margin-top: 1em; }
div.Bd-indent { margin-left: 4ch; }
table.Bl-column { width: 100%; }
table.foot { margin-top: 1em; }
table.tbl td:not(:last-child) { border-right: solid 1px; }
table.tbl tr:first-child { border-bottom: 2px solid black; }
table.tbl td { padding: 0 1em; }

div.manual-text a.Xr {
	color: inherit !important;

div.manual-text a.Xr:hover {
	text-decoration: none !important;

I then created the /var/git and /var/git/repositories directories, and ran an adaptation of the script used to setup gemini hosting:


# Define the base directory for repositories

# Loop through all the user directories in the 'members' group
for username in $(getent group members | cut -d: -f4 | tr ',' ' '); do
  # Create the repository directory with the user's permissions

  if [ ! -d "$repo_dir" ]; then
    mkdir -m 0755 -p "$repo_dir"

  # Ensure that the directory is owned by the user
  chown $username:$username "$repo_dir"

  # Create a symbolic link to the user's home directory
  if [ ! -h "/home/$username/git" ]; then
    ln -s "$repo_dir" "/home/$username/git"

  echo "Created directory and symlink for $username."

echo "Symlink creation completed."

You also need a package called fcgiwrap, just install it and then run:

systemctl enable fcgiwrap --now

And I added the relevant section to my Nginx config (/etc/nginx/conf.d/02-git.conf):

server {

    listen 80;
    listen [::]:80;

    # SSL configuration
    listen 443 ssl;
    listen [::]:443 ssl;

    access_log  /var/log/nginx/cgit-access.log;
    error_log   /var/log/nginx/cgit-error.log;

    root /usr/share/cgit;
    try_files $uri @cgit;

    location @cgit {
        include          fastcgi_params;
        fastcgi_param    SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
        fastcgi_pass     unix:/run/fcgiwrap.socket;

        fastcgi_param    PATH_INFO    $uri;
        fastcgi_param    QUERY_STRING $args;
        fastcgi_param    HTTP_HOST    $server_name;

Notes on Git

For some reason, the repositories I exported from Gitea all contained a file called “description”, which contained a long message telling you to edit it. cgit automatically uses the contents of this file as repo description. If you remove the files, it just shows “[no description]”, which is way cleaner in my opinion.

find . -type f -name "description" -exec rm -r {} \;

Edit: this is normal. When you initialize a bare Git repo, this file will always be created (at least for me). I decided to delete it and use the [gitweb] section in .config instead.

Also, for READMEs to work, you have to install these dependencies:

sudo apt install python3-markdown python3-docutils groff

And, last of all, for mirroring your repos to Codeberg, I will post something very soon.


I installed the UFW firewall:

sudo apt install ufw
ufw allow 20/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # SSL
ufw allow 8448/tcp # Matrix (federation)
ufw allow 1965/tcp # Gemini
ufw allow 5222/tcp # XMPP
ufw allow 5223/tcp # XMPP SSL
ufw allow 5296/tcp # XMPP server
ufw allow 8010/tcp # XMPP file transfers
ufw enable

I also installed fail2ban, which automatically bans bots spamming port 20:

sudo apt install fail2ban
systemctl enable --now fail2ban

Admin permissions

I created the group mod and gave it write permissions over /var/www and /var/gemini, for maintaining the website from a non-sudo account.

addgroup mod
usermod -aG mod <username>
chown root:mod <somedirectory>
chmod g+w <somedirectory>


These articles helped a lot: