Skip to main content

Setting Up a Content Repository for Ode

Ode separates code and content so you can keep your writing clean, portable, and easy to deploy. This guide explains how to create a content-only repository and connect it to an Ode deployment.

1. What This Repository Contains​

note

For theming and visual customization, see THEMING.md

Your content repo mirrors the public/ directory structure:

config.yaml
content/
intro.md
pieces/
pages/
favicon.ico (optional)

Example content/pieces/example.md:

---
title: "Example Piece"
slug: "example-piece"
date: 2025-01-01
collections: ["essays"]
---

Your writing here.
note

The description key in frontmatter is optional. If provided, it will be used as the RSS feed description for this piece or page. If not, a default will be generated automatically with the syntax: A piece from siteTitle | (first line of your piece)

2. Clone the Content Repo on Your Server​

Choose a directory for your site content:

cd /srv
git clone git@github.com:YOUR_USER/YOUR_CONTENT_REPO.git my-ode-site

This directory will be mounted into the Ode container.

3. Connect Content to Ode With Docker Compose​

Create a docker-compose.yml and mount the content repository using an absolute path:

services:
ode:
image: ghcr.io/dimwitlabs/ode:latest
ports:
- "8080:4173"
restart: unless-stopped
volumes:
- /srv/my-ode-site:/app/public
note

Use an absolute path for the volume mount. Relative paths may not work correctly with tools like Portainer.

Start the container:

docker compose up -d

The container will build and serve your site. Restart to rebuild after content changes:

docker compose restart ode

4. GitHub Secrets (Required)​

SSH Key Setup​

Generate an SSH key (ed25519 recommended):

ssh-keygen -t ed25519 -C "ode-deploy"

Press enter to accept defaults. When prompted for a passphrase, leave it empty for GitHub Actions.

This creates:

~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub

Add the Public Key to the Server​

Copy the public key:

cat ~/.ssh/id_ed25519.pub

SSH into your server:

ssh root@your-server-ip

On the server:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys

Paste the public key on its own line, then:

chmod 600 ~/.ssh/authorized_keys

Test the SSH Connection​

From your local machine:

ssh -i ~/.ssh/id_ed25519 root@your-server-ip

If this works without prompting for a password, SSH is configured correctly. Exit with Ctrl + D.

Add Secrets to GitHub​

In your content repo, go to Settings → Secrets and variables → Actions and add:

SecretDescription
SSH_HOSTServer IP or domain
SSH_USERUser with SSH + Docker access (e.g. root)
SSH_KEYFull private key contents (including -----BEGIN/END----- lines)
SSH_PORTSSH port (usually 22)

For SSH_KEY, paste the entire private key including:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

5. Auto-Deploy Content via GitHub Actions​

Add this file in your content repo at .github/workflows/deploy.yml:

name: Deploy content

on:
push:
branches: ["main"]

jobs:
deploy:
runs-on: ubuntu-latest

env:
PROJECT_NAME: your-project
APP_DIR: your-site
BACKUP_DIR: your-site.backup
SERVICE_NAME: ode
REPO_URL: git@github.com:YOUR_USER/YOUR_CONTENT_REPO.git

steps:
- name: Destructive deploy and restart Ode
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
envs: PROJECT_NAME,APP_DIR,BACKUP_DIR,SERVICE_NAME,REPO_URL
script: |
set -e
echo "⚠️ DESTRUCTIVE DEPLOY: /root/${APP_DIR} will be replaced (previous state kept at /root/${BACKUP_DIR})"
cd /root
if [ -d "${APP_DIR}" ]; then
rm -rf "${BACKUP_DIR}"
mv "${APP_DIR}" "${BACKUP_DIR}"
fi
git clone "${REPO_URL}" "${APP_DIR}"
cd "${APP_DIR}"
docker compose -p "${PROJECT_NAME}" up -d --force-recreate "${SERVICE_NAME}"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep "${PROJECT_NAME}-${SERVICE_NAME}" || true
STATIC_DIR="/var/www/${PROJECT_NAME}-static"
[ ! -d "${STATIC_DIR}" ] && mkdir -p "${STATIC_DIR}"
GENERATED_502="${APP_DIR}/generated/502.html"
echo "Waiting for 502 page..."
for i in {1..15}; do
if [ -f "${GENERATED_502}" ]; then
cp "${GENERATED_502}" "${STATIC_DIR}/502.html"
echo "502 page copied successfully"
break
fi
sleep 1
done
warning

This workflow is destructive. On every push: the server directory is deleted, fresh-cloned, and only one backup is kept. Ensure all content lives in Git.

6. Alternative: Portainer Webhook​

If using Portainer, you can use a webhook instead of SSH:

  1. In Portainer, go to your container → Webhooks
  2. Enable and copy the webhook URL
  3. Create .github/workflows/deploy.yml:
name: Deploy Ode content

on:
push:
branches: ["main"]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Pull content and restart
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd /srv/my-ode-site
git pull

- name: Trigger Portainer Webhook
run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"

Add PORTAINER_WEBHOOK_URL to your repo secrets.

7. Deployment Flow​

  1. You push new content
  2. GitHub Action runs
  3. Server pulls latest content
  4. Container restarts
  5. Site rebuilds with new content

8. Manual Commands (For Debugging)​

# Pull latest content
cd /srv/my-ode-site
git pull

# Restart container
docker restart YOUR_CONTAINER_NAME

# Check logs
docker logs YOUR_CONTAINER_NAME --tail 50

Summary​

  • Keep content in a separate repo mirroring public/ structure
  • Mount the entire repo to /app/public in the container
  • Use GitHub Actions for auto-deploy on push
  • Restart the container to rebuild with new content