DEV Community

Cover image for The Home Server Journey - 6: Your New Blogging Career
Beppe
Beppe

Posted on

The Home Server Journey - 6: Your New Blogging Career

Hello, folks!

One thing that bothers me when writing about self-hosting, to have greater control of my data, is that I don't apply those principles to the articles themselves. I mean, there is no immediate risk of Minds or Dev.to taking down my publications, but the mere fact that they could do that leaves me concerned

The Right Tool for the Job

First I've looked at the tools I was already familiar with. I have some old blog where I've posted updates during my Google Summer of Code projects. It uses Jekyll to generate static files, automatically published by GitHub Pages. It works very well when you have the website tied to a version-controlled repository, but it's cumbersome when you need to rebuild container images or replace files in a remote volume even for small changes

When looking for something more dynamic, I initially though about using Plume, since it's easy to integrate with some applications I plan to deploy later, but unfortunately it's not well maintained anymore. As Ghost or Wordpress seem overkill, I ended up opting for the conveniences of WriteFreely: it lets me create and edit posts in-place, with Markdown support and no need to upload new files. However, that comes with a cost: it requires a MySQL[-compatible] database

Contrarian Vibes

It seems easy enough to just deploy a MySQL container and use it, right? Well... It seems that there are some concerns about its licensing and development direction ever since the brand has been bought by Oracle (remember OpenOffice?). That was the motivation for the MariaDB fork, distributed under the GPLv2 license, which nowadays is not even a 100% drop-in replacement for MySQL, but still works for our case

Reputation-related shenanigans aside, one great advantage of picking MariaDB is the ability to use a Galera Cluster. Similarly to what we did for PostgreSQL, I wish to be able to scale it properly, and Galera's replication works in an even more interesting manner, with multiple primary (read-write) instances and no need for a separate proxy!:

MariaDB-Galera topology
(Man, I wish PostgreSQL had something similar...)

Of course that requires a more complex setup for the database server itself, but thanks to Bitnami's mariadb-galera Docker image and Helm chart, I've managed to get to something rather manageable for our purposes:

apiVersion: v1 kind: ConfigMap metadata: name: mariadb-config labels: app: mariadb data: BITNAMI_DEBUG: "false" # Set to "true" for more debug information MARIADB_GALERA_CLUSTER_NAME: galera # All pods being synchronized (has to reflect the number of replicas) MARIADB_GALERA_CLUSTER_ADDRESS: gcomm://mariadb-state-0.mariadb-replication-service.default.svc.cluster.local,mariadb-state-1.mariadb-replication-service.default.svc.cluster.local MARIADB_DATABASE: main # Default database MARIADB_GALERA_MARIABACKUP_USER: backup # Replication user --- # Source: mariadb-galera/templates/secrets.yaml apiVersion: v1 kind: Secret metadata: name: mariadb-secret labels: app: mariadb data: MARIADB_ROOT_PASSWORD: bWFyaWFkYg== # Administrator password MARIADB_GALERA_MARIABACKUP_PASSWORD: YmFja3Vw # Replication user password --- apiVersion: v1 kind: ConfigMap metadata: name: mariadb-cnf-config labels: app: mariadb data: # Database server configuration my.cnf: | [client] port=3306 socket=/opt/bitnami/mariadb/tmp/mysql.sock plugin_dir=/opt/bitnami/mariadb/plugin [mysqld] explicit_defaults_for_timestamp default_storage_engine=InnoDB basedir=/opt/bitnami/mariadb datadir=/bitnami/mariadb/data plugin_dir=/opt/bitnami/mariadb/plugin tmpdir=/opt/bitnami/mariadb/tmp socket=/opt/bitnami/mariadb/tmp/mysql.sock pid_file=/opt/bitnami/mariadb/tmp/mysqld.pid bind_address=0.0.0.0 ## Character set ## collation_server=utf8_unicode_ci init_connect='SET NAMES utf8' character_set_server=utf8 ## MyISAM ## key_buffer_size=32M myisam_recover_options=FORCE,BACKUP ## Safety ## skip_host_cache skip_name_resolve max_allowed_packet=16M max_connect_errors=1000000 sql_mode=STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_AUTO_VALUE_ON_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY sysdate_is_now=1 ## Binary Logging ## log_bin=mysql-bin expire_logs_days=14 # Disabling for performance per http://severalnines.com/blog/9-tips-going-production-galera-cluster-mysql sync_binlog=0 # Required for Galera binlog_format=row ## Caches and Limits ## tmp_table_size=32M max_heap_table_size=32M # Re-enabling as now works with Maria 10.1.2 query_cache_type=1 query_cache_limit=4M query_cache_size=256M max_connections=500 thread_cache_size=50 open_files_limit=65535 table_definition_cache=4096 table_open_cache=4096 ## InnoDB ## innodb=FORCE innodb_strict_mode=1 # Mandatory per https://github.com/codership/documentation/issues/25 innodb_autoinc_lock_mode=2 # Per https://www.percona.com/blog/2006/08/04/innodb-double-write/ innodb_doublewrite=1 innodb_flush_method=O_DIRECT innodb_log_files_in_group=2 innodb_log_file_size=128M innodb_flush_log_at_trx_commit=1 innodb_file_per_table=1 # 80% Memory is default reco. # Need to re-evaluate when DB size grows innodb_buffer_pool_size=2G innodb_file_format=Barracuda [galera] wsrep_on=ON wsrep_provider=/opt/bitnami/mariadb/lib/libgalera_smm.so wsrep_sst_method=mariabackup wsrep_slave_threads=4 wsrep_cluster_address=gcomm:// wsrep_cluster_name=galera wsrep_sst_auth="root:" # Enabled for performance per https://mariadb.com/kb/en/innodb-system-variables/#innodb_flush_log_at_trx_commit innodb_flush_log_at_trx_commit=2 # MYISAM REPLICATION SUPPORT # wsrep_mode=REPLICATE_MYISAM [mariadb] plugin_load_add=auth_pam --- apiVersion: apps/v1 kind: StatefulSet metadata: name: mariadb-state spec: serviceName: mariadb-replication-service # Use the internal/headless service name replicas: 2 selector: matchLabels: app: mariadb template: metadata: labels: app: mariadb spec: securityContext: # Container is not run as root fsGroup: 1001 runAsUser: 1001 runAsGroup: 1001 containers: - name: mariadb image: docker.io/bitnami/mariadb-galera:11.5.2 imagePullPolicy: "IfNotPresent" command: - bash - -ec - | exec /opt/bitnami/scripts/mariadb-galera/entrypoint.sh /opt/bitnami/scripts/mariadb-galera/run.sh ports: - name: mdb-mysql-port containerPort: 3306 # External access port (MySQL's default) - name: mdb-galera-port containerPort: 4567 # Internal process port - name: mdb-ist-port containerPort: 4568 # Internal process port - name: mdb-sst-port containerPort: 4444 # Internal process port envFrom: - configMapRef: name: mariadb-config env: - name: MARIADB_ROOT_PASSWORD valueFrom: secretKeyRef: name: mariadb-secret key: MARIADB_ROOT_PASSWORD - name: MARIADB_GALERA_MARIABACKUP_PASSWORD valueFrom: secretKeyRef: name: mariadb-secret key: MARIADB_GALERA_MARIABACKUP_PASSWORD volumeMounts: - name: previous-boot mountPath: /opt/bitnami/mariadb/.bootstrap - name: mariadb-data mountPath: /bitnami/mariadb - name: mariadb-cnf mountPath: /bitnami/conf/my.cnf # Overwrite any present configuration subPath: my.cnf - name: empty-dir mountPath: /tmp subPath: tmp-dir - name: empty-dir mountPath: /opt/bitnami/mariadb/conf subPath: app-conf-dir - name: empty-dir mountPath: /opt/bitnami/mariadb/tmp subPath: app-tmp-dir - name: empty-dir mountPath: /opt/bitnami/mariadb/logs subPath: app-logs-dir volumes: - name: previous-boot emptyDir: {} # Use a fake directory for mounting unused but required paths - name: mariadb-cnf configMap: name: mariadb-cnf-config - name: empty-dir emptyDir: {} # Use a fake directory for mounting unused but required paths volumeClaimTemplates: # Description of volume claim created for each replica - metadata: name: mariadb-data spec: storageClassName: nfs-small accessModes: - ReadWriteOnce resources: requests: storage: 8Gi --- # Headless service for internal replication/backup processes apiVersion: v1 kind: Service metadata: name: mariadb-replication-service labels: app: mariadb spec: type: ClusterIP clusterIP: None ports: - name: mariadb-galera-service port: 4567 targetPort: mdb-galera-port appProtocol: mysql - name: mariadb-ist-service port: 4568 targetPort: mdb-ist-port appProtocol: mysql - name: mariadb-sst-service port: 4444 targetPort: mdb-sst-port appProtocol: mysql publishNotReadyAddresses: true --- # Exposed service for external access apiVersion: v1 kind: Service metadata: name: mariadb-service spec: type: LoadBalancer # Let it be accessible inside the local network selector: app: mariadb ports: - port: 3306 targetPort: mdb-mysql-port appProtocol: mysql 
Enter fullscreen mode Exit fullscreen mode

Incredibly, it works. My deployment has been running without issue for some time now:

$ kubectl get all -n choppa -l app=mariadb NAME READY STATUS RESTARTS AGE pod/mariadb-state-0 1/1 Running 2 (3d1h ago) 5d3h pod/mariadb-state-1 1/1 Running 2 (3d1h ago) 5d3h NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/mariadb-replication-service ClusterIP None <none> 4567/TCP,4568/TCP,4444/TCP 5d3h service/mariadb-service LoadBalancer 10.43.40.243 192.168.3.10,192.168.3.12 3306:31594/TCP 5d3h NAME READY AGE statefulset.apps/mariadb-state 2/2 5d3h 
Enter fullscreen mode Exit fullscreen mode

(The 2 restarts were due to a power outage that exceeded the autonomy of my no-break's battery)

Solving one Problem to Reveal Another

I just started typing my first self-hosted blog post to realize something was missing: images. On Jekyll I had a folder for that, but on Minds and Dev.to they are hosted somewhere else, e.g. https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v2221vgcikcr05hmnj4v.png

If complete self-hosting is a must, I now need some file server capable of generating shareable links, to be used in my Markdown image components. In summary, Syncthing is great for Dropbox-style backups, but can't share links, NextCloud is too resource-heavy and Seafile is interesting but apparently has proprietary encryption, which left me with the lightweight Filebrowser

I don't expect or intend my file server to ever deal with a huge number of requests, so I've ran it as a simple deployment with a single pod:

kind: PersistentVolumeClaim # Storage requirements component apiVersion: v1 metadata: name: filebrowser-pv-claim labels: app: filebrowser spec: storageClassName: nfs-big # The used storage class (1TB drive) accessModes: - ReadWriteOnce #- ReadWriteMany # For concurrent access (In case I try to use more replicas) resources: requests: storage: 200Gi # Asking for a ~50 Gigabytes volume --- apiVersion: v1 kind: ConfigMap metadata: name: filebrowser-config labels: app: filebrowser data: # Application settings file .filebrowser.json: | { "port": 80, "baseURL": "", "address": "", "log": "stdout", "database": "/srv/filebrowser.db", "root": "/srv" } --- apiVersion: apps/v1 kind: Deployment metadata: name: filebrowser-deploy spec: replicas: 1 strategy: type: Recreate # Wait for the old container to be terminated before creating a new one selector: matchLabels: app: filebrowser template: metadata: labels: app: filebrowser spec: # Run this initial container to make sure at least an empty  # database file exists prior to the main container starting, # as a workaround for a know bug (https://filebrowser.org/installation#docker) initContainers: - name: create-database image: busybox command: ["/bin/touch","/srv/filebrowser.db"] volumeMounts: - name: filebrowser-data mountPath: /srv containers: - name: filebrowser image: filebrowser/filebrowser:latest imagePullPolicy: IfNotPresent ports: - name: file-port containerPort: 80 protocol: TCP volumeMounts: - name: filebrowser-readonly mountPath: /.filebrowser.json subPath: .filebrowser.json - name: filebrowser-data mountPath: /srv volumes: - name: filebrowser-readonly configMap: name: filebrowser-config - name: filebrowser-data # Label the volume for this deployment persistentVolumeClaim: claimName: filebrowser-pv-claim # Reference volumen create by the claim --- apiVersion: v1 kind: Service metadata: name: filebrowser-service spec: type: NodePort # Expose the service outside the cluster with an specific port selector: app: filebrowser ports: - protocol: TCP port: 8080 targetPort: file-port nodePort: 30080 
Enter fullscreen mode Exit fullscreen mode

(That's what I did here, make it makes the filebrowser.db file end up visible inside the root folder. It's probably a good idea to use subpaths and mount them separately e.g. srv/filebrowser.db and srv/data for root)

We can't upload or access the files from the Internet yet, but using NodePort an external port in the range 30000-32767 can be used to reach it locally. Use the default username admin and password admin to login and then change it in the settings:

Filebrowser screen

Click on each file you wish to share and the option to generate links will appear on the top. In Markdown syntax, shared images may be annexed with the statement ![Image description](https://<your host>/api/public/dl/<share hash>?inline=true)

One Step Forward. Two Steps Back

All set to deploy WriteFreely, right? As you might guess, no

The application doesn't have an official Docker image, and the custom ones available are either too old or not available for the ARM64 architecture. The repository provided by karlprieb is a good base to build your own, but it lead to crashes here when compiling the application itself. In the end, I found it easier to create one taking advantage of Alpine Linux's packages:

  • Dockerfile
FROM alpine:3.20 LABEL org.opencontainers.image.description="Simple WriteFreely image based on https://git.madhouse-project.org/algernon/writefreely-docker" # Install the writefreely package RUN apk add --no-cache writefreely # Installation creates the writefreely user, so let's use it # to run the application RUN mkdir /opt/writefreely && chown writefreely -R /opt/writefreely COPY --chown=writefreely:writefreely ./run.sh /opt/writefreely/ RUN chmod +x /opt/writefreely/run.sh # Base directory and exposed container port WORKDIR /opt/writefreely/ EXPOSE 8080 # Set the default container user and group USER writefreely:writefreely # Start script ENTRYPOINT ["/opt/writefreely/run.sh"] 
Enter fullscreen mode Exit fullscreen mode
  • Entrypoint script (run.sh)
#! /bin/sh writefreely -c /data/config.ini --init-db writefreely -c /data/config.ini --gen-keys if [ -n "${WRITEFREELY_ADMIN_USER}" ] && [ -n "${WRITEFREELY_ADMIN_PASSWORD}" ]; then writefreely -c /data/config.ini --create-admin "${WRITEFREELY_ADMIN_USER}:${WRITEFREELY_ADMIN_PASSWORD}" fi writefreely -c /data/config.ini 
Enter fullscreen mode Exit fullscreen mode

Here I've published the image to ancapepe/writefreely:latest on DockerHub, so use it if you wish and have no desire for alternative themes or other custom stuff. One more thing to do before running our blog is to prepare the database to receive its content, so log into the MariaDB server on port 3306 using you root user and execute those commands, replacing username and password to your liking:

CREATE DATABASE writefreely CHARACTER SET latin1 COLLATE latin1_swedish_ci; CREATE USER 'blog' IDENTIFIED BY 'my_password'; GRANT ALL ON writefreely.* TO 'blog'; 
Enter fullscreen mode Exit fullscreen mode

Now apply a K8s manifest matching previous configurations and adjusting new ones to your liking:

apiVersion: v1 kind: ConfigMap metadata: name: writefreely-config labels: app: writefreely data: WRITEFREELY_ADMIN_USER: my_user config.ini: | [server] hidden_host = port = 8080 bind = 0.0.0.0 tls_cert_path = tls_key_path = templates_parent_dir = /usr/share/writefreely static_parent_dir = /usr/share/writefreely pages_parent_dir = /usr/share/writefreely keys_parent_dir =  [database] type = mysql username = blog password = my_password database = writefreely host = mariadb-service port = 3306 [app] site_name = Get To The Choppa site_description = Notes on Conscious Self-Ownership host = https://blog.choppa.xyz editor =  theme = write disable_js = false webfonts = true landing = /login single_user = true open_registration = false min_username_len = 3 max_blogs = 1 federation = true public_stats = true private = false local_timeline = true user_invites = admin # If you wish to change the shortcut icon for your blog without modifying the image itself, add here the configmap entry generated by running `kubectl create configmap favicon-config --from-file=<your .ico image path>` binaryData: favicon.ico: <binary dump here> --- apiVersion: v1 kind: Secret metadata: name: writefreely-secret data: WRITEFREELY_ADMIN_PASSWORD: bXlfcGFzc3dvcmQ= --- apiVersion: apps/v1 kind: Deployment metadata: name: writefreely-deploy spec: replicas: 1 selector: matchLabels: app: writefreely template: metadata: labels: app: writefreely spec: containers: - name: writefreely image: ancapepe/writefreely:latest imagePullPolicy: "IfNotPresent" ports: - containerPort: 8080 name: blog-port env: - name: WRITEFREELY_ADMIN_USER valueFrom: configMapKeyRef: name: writefreely-config key: WRITEFREELY_ADMIN_USER - name: WRITEFREELY_ADMIN_PASSWORD valueFrom: secretKeyRef: name: writefreely-secret key: WRITEFREELY_ADMIN_PASSWORD volumeMounts: - name: writefreely-volume mountPath: /data/config.ini subPath: config.ini # Use this if you set the custom favicon.ico image above - name: writefreely-volume mountPath: /usr/share/writefreely/static/favicon.ico subPath: favicon.ico volumes: - name: writefreely-volume configMap: name: writefreely-config --- apiVersion: v1 kind: Service metadata: name: writefreely-service spec: publishNotReadyAddresses: true selector: app: writefreely ports: - protocol: TCP port: 8080 targetPort: blog-port 
Enter fullscreen mode Exit fullscreen mode

(You may add your own favicon.ico to the image itself if you're building it)

Almost there. Now we just have to expose both our blog pages and file server to the Internet by adding the corresponding entries to our ingress component:

apiVersion: networking.k8s.io/v1 kind: Ingress # Component type metadata: name: proxy # Component name namespace: choppa # You may add the default namespace for components as a paramenter annotations: cert-manager.io/cluster-issuer: letsencrypt kubernetes.io/ingress.class: traefik status: loadBalancer: {} spec: ingressClassName: traefik # Type of controller being used tls: - hosts: - choppa.xyz - talk.choppa.xyz - blog.choppa.xyz - files.choppa.xyz secretName: certificate rules: # Routing rules - host: choppa.xyz # Expected domain name of request, including subdomain http: # For HTTP or HTTPS requests paths: # Behavior for different base paths - path: / # For all request paths pathType: Prefix backend: service: name: welcome-service # Redirect to this service port: number: 8080 # Redirect to this internal service port - path: /.well-known/matrix/ pathType: ImplementationSpecific backend: service: name: conduit-service port: number: 8448 - host: talk.choppa.xyz http: paths: - path: / pathType: Prefix backend: service: name: conduit-service port: number: 8448 - host: test.choppa.xyz # Expected domain name of request, including subdomain http: # For HTTP or HTTPS requests paths: # Behavior for different base paths - path: / # For all request paths pathType: Prefix backend: service: name: test-service # Redirect to this service port: number: 80 # Redirect to this internal service port - host: blog.choppa.xyz http: paths: - path: / pathType: Prefix backend: service: name: writefreely-service port: number: 8080 - host: files.choppa.xyz http: paths: - path: / pathType: Prefix backend: service: name: filebrowser-service port: number: 8080 
Enter fullscreen mode Exit fullscreen mode

If everything went accordingly, you now have everything in place to log into your blog and start publishing. To get an idea of how your self-hosted articles will look like, pay a visit to the first chapter of this series that I'm starting to publish on my server as well:

Blog page

Thanks for following along. See you next time

Top comments (0)