Deploying Writefreely in Kubernetes with MySQL

I have been looking for something to replace WordPress for literally years. Last year, I started writing my own replacement (and got decently far), but in the end I found I have better things to do.

Deploying Writefreely in Kubernetes with MySQL
Photo by Growtika / Unsplash

I have been looking for something to replace WordPress for literally years. Last year, I started writing my own replacement (and got decently far), but in the end I found I have better things to drink do.

The past year, I've been digging in to the Fediverse, and am now running a Pleroma/Soapbox instance, a Pixelfed instance (which is barely working and work in progress), and a PeerTube instance, so it would be natural to find blogging software that can seamlessly integrate with that.

Plume is more or less dead, so the only real option is Writefreely. Writefreely has been nearing version 1 for ages, and is a bit of a bother to run in a production environment. They do not natively support Docker deployments and there are no good helm charts.

I found three helm charts, but they all use Docker images without current updates. There is a current Docker image, but it does not support MySQL natively, and the patched version does not support the latest version. I did not feel like making my own image because I have no desire to maintain it.

I therefore went with the current image and custom configuration. I made my own k8s resources to deploy the image including a PV for data and an ingress with an automatically provisioned certificate:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: writefreely
  namespace: default
  labels:
    app.kubernetes.io/instance: writefreely
    app.kubernetes.io/name: writefreely
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/instance: writefreely
      app.kubernetes.io/name: writefreely
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app.kubernetes.io/instance: writefreely
        app.kubernetes.io/name: writefreely
    spec:
      initContainers:
      - args:
        - -ec
        - |
          mkdir -p /data
          find /data -mindepth 0 -maxdepth 1 -not -name ".snapshot" -not -name "lost+found" | xargs -r chown -R 5000:5000
        command: 
        - /bin/bash
        image: docker.io/bitnami/bitnami-shell:11-debian-11-r92
        imagePullPolicy: IfNotPresent
        name: volume-permissions
        resources: {}
        securityContext:
          runAsUser: 0
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /data
          name: writefreely
          subPath: writefreely
      containers:
      - name: writefreely
        image: algernon/writefreely:0.14.0-1
        imagePullPolicy: IfNotPresent
        env:
          - name: WRITEFREELY_BIND_PORT
            value: '8080'
          - name: WRITEFREELY_SITE_NAME
            value: 'Michaelpedia Galactica'
          - name: WRITEFREELY_SINGLE_USER
            value: 'true'
          - name: WRITEFREELY_OPEN_REGISTRATION
            value: 'false'
          - name: WRITEFREELY_FEDERATION
            value: 'true'
          - name: WRITEFREELY_PUBLIC_STATS
            value: 'false'
          - name: WRITEFREELY_PRIVATE
            value: 'false'
          - name: WRITEFREELY_LOCAL_TIMELINE
            value: 'true'
          - name: WRITEFREELY_USER_INVITES
            value: 'admin'
          - name: SSL_CERT_DIR
            value: '/data/ca'
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP
        livenessProbe:
          failureThreshold: 6
          initialDelaySeconds: 10
          periodSeconds: 10
          successThreshold: 1
          tcpSocket:
            port: http
          timeoutSeconds: 5
        readinessProbe:
          failureThreshold: 3
          initialDelaySeconds: 5
          periodSeconds: 5
          successThreshold: 1
          tcpSocket:
            port: http
          timeoutSeconds: 3
        resources:
          limits:
            cpu: 250m
            memory: 256Mi
          requests:
            cpu: 50m
            memory: 128Mi
        startupProbe:
          failureThreshold: 6
          initialDelaySeconds: 5
          periodSeconds: 2
          successThreshold: 1
          tcpSocket:
            port: http
          timeoutSeconds: 5
        volumeMounts:
        - mountPath: /data
          name: writefreely
          subPath: writefreely
      volumes:
      - name: writefreely
        persistentVolumeClaim:
          claimName: writefreely
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    app.kubernetes.io/instance: writefreely
    app.kubernetes.io/name: writefreely
  name: writefreely
  namespace: default
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: writefreely
spec:
  type: ClusterIP
  ports:
    - protocol: TCP
      name: http
      port: 80
      targetPort: 8080
  selector:
    app.kubernetes.io/instance: writefreely
    app.kubernetes.io/name: writefreely
  sessionAffinity: None
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
    kubernetes.io/ingress.provider: nginx
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
  labels:
    app.kubernetes.io/instance: writefreely
    app.kubernetes.io/name: writefreely
  name: writefreely
  namespace: default
spec:
  rules:
  - host: westergaard.blog
    http:
      paths:
      - backend:
          service:
            name: writefreely
            port:
              name: http
        path: /
        pathType: ImplementationSpecific
  - host: '*.westergaard.blog'
    http:
      paths:
      - backend:
          service:
            name: writefreely
            port:
              name: http
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - westergaard.blog
    - '*.westergaard.blog'
    secretName: westergaard-blog-tls

The Deployment comes with an init container to ensure proper permissions. I'm just using the Bitnami image for this because it works for other deployments I have. A regular alpine image is likely fine. This needed for DigitalOcean deployments (referral link giving you $200 credit and me $25).

The main container contains my configuration for the blog. The BIND_PORT and SSL_CERT_DIR should be kept as is, but the others can be freely changed.

The ingress uses my domain westergaard.blog, and should be changed for other uses.

MySQL from DigitalOceann requires TLS, and uses a certificate not in the image's CA roots. By setting SSL_CERT_DIR, I can manually download that to /data/ca once and it is recognized.

A minimal 1 GiB PV is put in /data – feel free to change this, though there is little reason. I have copied templates, pages, and themes from /writefreely to /data so I can customize them.

After installing with kubectl apply -f writefreely.yaml, I log in to the pod using kubectl exec -t -i <pod> -- ash and can edit /data/config.ini to use MySQL according to Writefreely's documentation. Make sure not to remove /data/writefreely.db or the image will have issues when starting up again. Initialize the database from /data while logged in to the image using /writefreely/writefreely db init; you should see it successfully connecting to and creating the database.

Now, restart the pod (just delete the pod using kubectl delete pod <pod> and Kubernetes will recreate it), and it should come back up, now running on MySQL. Log in to the pod to create the admin user in /data using /writefreely/writefreely --create-admin <name>:<password>. Log our and restart the pod again (to clear the command history with your admin password), and you should be good to go.