How to Deploy Outline Wiki on Kubernetes

A year ago, I was tasked with finding a knowledge base/wiki that could be self-hosted, was intuitive to use, and was visually appealing. In that time, I tried BookStack, DokuWiki, and WikiJS; while these wikis all did what they needed to, they didn't quite fit what we were looking for. DokuWiki wasn't as intuitive for the non-technical users in our organization, BookStack felt a little to rigid in what you could and couldn't achieve, and WikiJS took quite a while for everyone to wrap their heads around.

At the beginning of this process, I stumbled across an interesting project called Outline Wiki. I loved how this looked, especially considering how visually similar it is to Notion, which I use a lot in my personal projects and for general note-taking. The issue with Outline, was that its documentation for the installation process was less than ideal. The GitHub repo has better information on it now than it did when I first stumbled across it, but it still doesn't provide straight to the point instructions on a simple deployment; this is partially due to the fact that it has mandatory dependencies such as PostgreSQL, Redis, and an S3-compatible storage. For someone looking to deploy a simple wiki, this sounds pretty heavy.

Most of my organization's internal resources are hosted on a Kubernetes cluster. We utilize Digital Ocean's managed offering for simplicity, and I can confirm that this guide works perfectly on version 1.16.15-do.2 of this service, and there's no reason for me to believe that it won't work just as well on future versions.


Prerequisites

You should already have the following resources setup:

Setup

For this guide, we'll be using MinIO as our S3-compatible storage provider. You can just as easily swap out a few parts of this guide to utilize Amazon S3 instead. We'll also be using the official Docker images for PostgreSQL and Redis.

For starters, ensure that you have kubectl configured for your Kubernetes cluster. To test this, try running the following from your command-line:

$ kubectl get pods
NAME                                                   READY   STATUS    RESTARTS   AGE
nginx-nginx-ingress-controller-79d9c4cd58-m6lb6        1/1     Running   0          78d
nginx-nginx-ingress-default-backend-6d96c457f6-ptqkg   1/1     Running   0          78d

As long as the command doesn't return an error, then you should be good to go!

I'll be providing a full YAML file at the bottom of this guide, but I'll walk through each component of it first to give you a better idea of how it all works. You can paste of each of these code samples into individual YAML files and create each resource individually by running kubectl apply -f resource.yaml.

Namespace

The first resource we'll need to create is a namespace for our resources to live within. It's important to keep different projects within different namespaces for safety and for organization. A namespace can be created from YAML like so:

apiVersion: v1
kind: Namespace
metadata:
  name: outline

ConfigMap

Next, we'll create a ConfigMap. This will provide the environment variables that Outline (and its dependencies) will read from at runtime. In this guide, we opted to combine all of the resources' ConfigMaps into a single ConfigMap. We chose to do this as there were a few resources that positively overlapped with no side-affects. You may decide to do this differently. Here's an example ConfigMap for Outline:

apiVersion: v1
kind: ConfigMap
metadata:
  name: outline
  namespace: outline
data:
  AWS_REGION: xx-xxxx-x
  AWS_S3_ACL: private
  AWS_S3_FORCE_PATH_STYLE: "true"
  AWS_S3_UPLOAD_BUCKET_NAME: wiki
  AWS_S3_UPLOAD_BUCKET_URL: http://localhost:9000
  AWS_S3_UPLOAD_MAX_SIZE: "26214400"
  CDN_URL: ""
  DATABASE_URL: postgres://outline:Outline123@localhost:5432/outline
  DATABASE_URL_TEST: postgres://outline:Outline123@localhost:5432/outline-test
  DEBUG: cache,presenters,events,emails,mailer,utils,multiplayer,server,services
  DEFAULT_LANGUAGE: en_US
  ENABLE_UPDATES: "true"
  FORCE_HTTPS: "true"
  GOOGLE_ALLOWED_DOMAINS: your-domain.tld
  GOOGLE_ANALYTICS_ID: ""
  PGSSLMODE: disable
  PORT: "80"
  REDIS_URL: redis://localhost:6379
  SENTRY_DSN: ""
  SLACK_MESSAGE_ACTIONS: "true"
  SMTP_FROM_EMAIL: [email protected]
  SMTP_HOST: smtp.your-mail-provider.tld
  SMTP_PORT: "587"
  SMTP_REPLY_EMAIL: [email protected]
  SMTP_USERNAME: [email protected]
  TEAM_LOGO: https://domain.tld/logo.png
  URL: https://wiki.your-domain.tld

There are quite a few variables to talk about here. Here are the variables you should know about:

  • AWS_REGION: if you're using Amazon S3, change this to your region slug.
  • AWS_S3_ACL: you'll probably want to keep this as private.
  • AWS_S3_UPLOAD_BUCKET_NAME: this is the storage bucket that Outline will use to store assets. Make sure that this is not shared by any other resources.
  • AWS_S3_UPLOAD_BUCKET_URL: for Amazon S3, this should be something like s3.eu-east-1.amazonaws.com where eu-east-1 is your region slug. For non-Amazon S3, you can either set this as localhost:9000 if you're creating MinIO for Outline, or you can set it as a public domain if you're planning on utilizing MinIO for other services too (e.g.: https://s3.your-domain.tld).
  • GOOGLE_ALLOWED_DOMAINS: a list of allowed domains that users can sign-up with.
  • SMTP_FROM_EMAIL: this is the address that Outline will use to send emails to users from. Setup an email account with your email provider and fill in the SMTP-related variables in this list. If you're using GSuite, you'll need to make sure that unsecure app access is enabled for the account and that app passwords are enabled.
  • TEAM_LOGO: this is the logo that Outline will use in-place of its own logo throughout your install. This should be hosted externally.

Secret

The secret will contain similar information to the ConfigMap above, but this information is sensitive and should therefore not be stored in plaintext. You will need to encode all of these values as Base64 before entering them into the secret. You can use online tools such as base64encode.org to achieve this:

apiVersion: v1
kind: Secret
metadata:
  name: outline
  namespace: outline
type: Opaque
data:
  AWS_ACCESS_KEY_ID: key_here
  AWS_SECRET_ACCESS_KEY: key_here
  GOOGLE_CLIENT_ID: key_here
  GOOGLE_CLIENT_SECRET: key_here
  MINIO_ACCESS_KEY: key_here
  MINIO_SECRET_KEY: key_here
  SECRET_KEY: key_here
  SLACK_APP_ID: key_here
  SLACK_KEY: key_here
  SLACK_SECRET: key_here
  SLACK_VERIFICATION_TOKEN: key_here
  SMTP_PASSWORD: password_here
  UTILS_SECRET: key_here

You'll want to generate most of these values using a tool such as bitwarden.com/password-generator for security. Most of these variables are pretty self-explanatory, so I won't go into detail for them. You can get the SLACK values from here. You do not need to provide the MINIO variables if you're using something other than MinIO in this example. If you are using it, you'll want to populate the AWS variables with the exact same values.

Persistent Volume Claim

We'll need a storage volume for MinIO and PostgreSQL to write to. We'll create one with 10GB of available space, but you can change this to anything that your cluster provider allows. We can achieve this by create a persistent volume claim as is shown below.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: outline
  namespace: outline
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

Deployment

The deployment is where we specify and create the Docker containers that Outline will utilize and rely upon. It's important to note that this should be created after the ConfigMap, Secret, and Persistent Volume Claim.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: outline
  namespace: outline
spec:
  selector:
    matchLabels:
      app: outline
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: outline
    spec:
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: outline
      containers:
      - name: outline
        image: outlinewiki/outline:latest
        command: ["sh", "-c", "yarn sequelize:migrate --env production-ssl-disabled && yarn start"]
        envFrom:
        - configMapRef:
            name: outline
        - secretRef:
            name: outline
        ports:
        - containerPort: 80
      - name: postgres
        volumeMounts:
        - name: data
          mountPath: "/var/lib/postgresql/data"
          subPath: postgres
        image: postgres:latest
        env:
        - name: POSTGRES_USER
          value: "outline"
        - name: POSTGRES_PASSWORD
          value: "Outline123"
        - name: POSTGRES_DB
          value: "outline"
        ports:
        - containerPort: 5432
      - name: redis
        image: redis:latest
        ports:
        - containerPort: 6379
      - name: minio
        volumeMounts:
        - name: data
          mountPath: "/data"
          subPath: minio
        image: minio/minio:latest
        args:
        - server
        - /data
        envFrom:
        - secretRef:
            name: outline
        ports:
        - containerPort: 9000
        readinessProbe:
          httpGet:
            path: /minio/health/ready
            port: 9000
          initialDelaySeconds: 120
          periodSeconds: 20
        livenessProbe:
          httpGet:
            path: /minio/health/live
            port: 9000
          initialDelaySeconds: 120
          periodSeconds: 20

You can remove lines 51 to 76 if you do not intend to self-host S3 via MinIO.

Service

The service is a very simple resource, which directs ingress traffic through to our chosen container. This will only expose port 80 for our Outline pod, which the Outline container listens on.

apiVersion: v1
kind: Service
metadata:
  name: outline
  namespace: outline
spec:
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
  selector:
    app: outline

Ingress

The ingress resource is the final part required to deploy Outline as a full service.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: outline
  namespace: outline
  annotations:
    kubernetes.io/ingress.class: "nginx"
    cert-manager.io/cluster-issuer: "letsencrypt-production"
spec:
  tls:
  - hosts:
    - wiki.your-domain.tld
    secretName: outline-tls
  rules:
  - host: wiki.your-domain.tld
    http:
      paths:
      - path: /
        backend:
          serviceName: outline
          servicePort: 80

This ingress utilizes a pre-established ClusterIssuer titled letsencrypt-production to obtain the required SSL certificate. Be sure to set the host variable to a valid domain/subdomain pointing to your cluster's public IP.

Let's Deploy It!

As promised above, here is a concatenated version of the above YAML components. You'll still need to swap out the placeholder values with your own prior to deploying this, otherwise things won't work.

If you have each YAML component in a separate file, you'll need to create them in a specific order (Namespace, ConfigMap, Secret, Persistent Volume Claim, Deployment, Service, Ingress). If they're all in a single file, you'll need to make sure they're still in the same order from top to bottom.

To deploy, simply run the following command for each of your YAML files (or the single one):

$ kubectl apply -f outline.yaml

Assuming there are no errors, the next thing you'll want to do is wait for the SSL certificate to be issued. You can monitor this process like so:

$ kubectl -n outline get certs
NAME          READY   SECRET        AGE
outline-tls   False   outline-tls   11s

A certificate is usually issued within 60 seconds if your Issuer has been configured correctly, though it can sometimes take up to 5 minutes. If it still isn't ready after this time, run kubectl -n outline describe certs to see if there's any debugging information you can find.

Once the certificate has been successfully issued (denoted by the READY header in the table given above), you should be able to visit your domain/subdomain in your browser and see the Outline Wiki login page!