Skip to main content

Build a blog with Decap CMS

This tutorial walks you through setting up a personal blog with a visual admin interface where you write articles in a browser — no Markdown editor required. Articles are stored in git (with images), built into static HTML, and auto-deployed to Flexweg on every push.

What you'll build

  • A blog running at http://localhost:8080 with hot reload
  • A visual admin panel at http://localhost:8080/admin powered by Decap CMS — write articles in a WYSIWYG editor, upload images, save with one click
  • A GitHub repository where every article is a markdown file in git
  • A GitHub Actions workflow that rebuilds and redeploys to Flexweg on every push to main
  • A live URL of the form https://your-subdomain.flexweg.com

Tech stack

  • Eleventy (11ty) — a simple, fast static site generator
  • Decap CMS — a self-hosted, git-based admin UI (formerly Netlify CMS)
  • Docker — isolates the dev environment, no Node.js needed on your host
  • GitHub Actions — builds and deploys on push

Before you start

Required setup

This tutorial assumes you've completed the Prerequisites — Docker Desktop + Git installed, Git configured, and free GitHub + Flexweg accounts ready.

Once that's done, come back and start at Step 1 below.


Step 1 — Open a terminal and pick your workspace

Open a terminal (Terminal on macOS/Linux, PowerShell on Windows), then navigate to the folder where you want to keep your projects:

cd ~/Desktop
# or: cd ~/Projects, or any folder you prefer

Step 2 — Create the project folder

Create a new folder for your blog and enter it:

mkdir my-blog
cd my-blog

All subsequent commands run from inside this folder.

Step 3 — Create package.json

At the root of your project, create a file named package.json:

package.json
{
"name": "my-blog",
"version": "1.0.0",
"scripts": {
"start": "eleventy --serve --port=8080",
"build": "eleventy",
"cms": "decap-server"
},
"devDependencies": {
"@11ty/eleventy": "^3.0.0",
"decap-server": "^3.0.0"
}
}

This declares two tools we'll run in parallel:

  • eleventy — builds your site and serves it with hot reload at port 8080.
  • decap-server — a small proxy (port 8081) that lets the Decap CMS admin UI read/write files on your machine during dev.

Step 4 — Create the docker-compose.yml

Two services: one for the site, one for the CMS backend. Both share the same node_modules volume so npm install only runs once.

docker-compose.yml
services:
site:
image: node:20-alpine
working_dir: /app
ports:
- "8080:8080"
volumes:
- .:/app
- node_modules:/app/node_modules
command: sh -c "npm install && npm start"

cms:
image: node:20-alpine
working_dir: /app
ports:
- "8081:8081"
volumes:
- .:/app
- node_modules:/app/node_modules
command: sh -c "npm install && npm run cms"

volumes:
node_modules:

What happens on docker compose up:

  • Port 8080 → your blog (served by 11ty with hot reload).
  • Port 8081 → Decap CMS backend (decap-server). The admin UI in your browser talks to this directly to read/write files on your filesystem.

Step 5 — Create the Eleventy config

Create .eleventy.js at the root:

.eleventy.js
module.exports = function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("src/admin");
eleventyConfig.addPassthroughCopy("src/static");
eleventyConfig.addPassthroughCopy("src/style.css");

return {
dir: {
input: "src",
output: "_site",
includes: "_includes",
},
};
};

What this does:

  • Source content lives in src/.
  • Built site is output to _site/.
  • The admin/, static/ folders and style.css are copied to the output as-is (not processed by 11ty).

Step 6 — Create the folder structure and layout

Create the source folders:

mkdir -p src/_includes src/admin src/posts src/static/uploads

Create the base HTML layout that wraps every page:

src/_includes/base.njk
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "My blog" }}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1><a href="/">My blog</a></h1>
<nav><a href="/">Home</a></nav>
</header>
<main>
{{ content | safe }}
</main>
<footer>
<p>© {% now "yyyy" %} My blog — built with Eleventy + Decap CMS, hosted on Flexweg.</p>
</footer>
</body>
</html>

Step 7 — Create the homepage

src/index.njk
---
layout: base.njk
title: My blog
---

<h2>Welcome</h2>
<p>Latest posts:</p>

<ul class="post-list">
{% for post in collections.posts | reverse %}
<li>
<a href="{{ post.url }}">{{ post.data.title }}</a>
<span class="post-date">— {{ post.date.toISOString().slice(0, 10) }}</span>
</li>
{% endfor %}
</ul>

This loops over every post and renders a list sorted newest first.

Step 8 — Configure the posts folder

Create a small data file that applies a layout and URL shape to every post in src/posts/:

src/posts/posts.11tydata.json
{
"tags": ["posts"],
"layout": "base.njk",
"permalink": "/posts/{{ page.fileSlug }}/"
}

From now on, any markdown file you drop into src/posts/ becomes a page at /posts/<filename>/ and is included in the posts collection used by the homepage.

Step 9 — Seed a first post

Create a sample post so the homepage isn't empty on first load:

src/posts/hello-world.md
---
title: Hello, world
date: 2026-04-23
---

Welcome to my first blog post! This file was created manually — in a minute we'll create the next one through the **Decap CMS admin UI** in our browser.

Edit this file or delete it whenever you like. Every `.md` file inside `src/posts/` automatically becomes a page.

Step 10 — Add minimal styling

src/style.css
body {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.65;
color: #1a1a1a;
}

header {
margin-bottom: 3rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}

h1 a { color: inherit; text-decoration: none; }
a { color: #0066cc; }

.post-list { list-style: none; padding: 0; }
.post-list li { padding: 0.5rem 0; border-bottom: 1px solid #f0f0f0; }
.post-date { color: #888; font-size: 0.9em; }

main img { max-width: 100%; height: auto; border-radius: 8px; }

footer {
margin-top: 4rem;
padding-top: 1rem;
border-top: 1px solid #eee;
color: #888;
font-size: 0.9em;
}

Feel free to customize — this is just a clean starting point.

Step 11 — Create the Decap CMS admin panel

The admin is a tiny HTML page that loads Decap CMS from a CDN.

src/admin/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content manager</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</body>
</html>

And its configuration file:

src/admin/config.yml
# Backend: during dev, Decap CMS talks to the local decap-server on port 8081.
# For production you'd switch to a git backend (github, gitlab). See "Next steps".
backend:
name: git-gateway
branch: main

# Enables the local proxy backend during development
local_backend: true

# Where uploaded images are stored and how they're referenced from posts
media_folder: "src/static/uploads"
public_folder: "/static/uploads"

# Collections define the content types editable from the admin UI
collections:
- name: "posts"
label: "Blog posts"
label_singular: "Post"
folder: "src/posts"
create: true
slug: "{{slug}}"
fields:
- { label: "Title", name: "title", widget: "string", required: true }
- { label: "Date", name: "date", widget: "datetime", format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: true }
- { label: "Featured image", name: "image", widget: "image", required: false, allow_multiple: false }
- { label: "Body", name: "body", widget: "markdown", required: true }

What this config says:

  • Posts collection lives in src/posts/ and is freely creatable from the UI (create: true).
  • Each post has four fields: title, date, optional featured image, markdown body.
  • Uploaded images go into src/static/uploads/ and are referenced in markdown as /static/uploads/filename.jpg.

Step 12 — Start the dev environment

From the project root:

docker compose up

Two containers start. The first run installs dependencies (~1 minute); subsequent runs are instant. Watch for both services reporting ready:

site_1 | [11ty] Server at http://localhost:8080/
cms_1 | Decap CMS proxy listening on 8081

Open two browser tabs:

Change to any file, see it live

Editing src/posts/hello-world.md, src/style.css, or .eleventy.js triggers 11ty to rebuild and the browser to reload automatically. Same for files created through the admin.

Step 13 — Write your first post through Decap CMS

In the admin UI (http://localhost:8080/admin/):

  1. Click the Blog posts collection in the left sidebar.
  2. Click New Post in the top right.
  3. Fill in Title, Date, optionally drop an image in Featured image, and write your article in the Body field (markdown toolbar included).
  4. Click Save in the top right.

A new src/posts/<slug>.md file appears on your disk — that's the CMS writing to your filesystem via the proxy. Refresh the blog tab: your post is live.

That's the full editing loop. Every change you make through the CMS is just a markdown file on your machine — you can read, edit, or commit them as plain text whenever you want.

Step 14 — Create the sync script

This script uploads your built _site/ folder to Flexweg via the Files API. Same pattern as the Docusaurus tutorial, just pointing at _site/ instead of build/.

sync-flexweg.js
// Upload every file in _site/ to Flexweg via the REST API.
const fs = require('fs');
const path = require('path');

const API_KEY = process.env.FLEXWEG_API_KEY;
const BASE_URL = process.env.FLEXWEG_BASE_URL || 'https://www.flexweg.com';
const BUILD_DIR = '_site';

const BINARY = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'ico', 'pdf', 'woff', 'woff2', 'ttf', 'otf']);
const TEXT = new Set(['html', 'css', 'js', 'json', 'xml', 'txt', 'svg']);

const RATE_PER_MIN = 50;
const GAP_MS = Math.ceil(60000 / RATE_PER_MIN);
let lastAt = 0;
let errors = 0;

async function rateLimit() {
const wait = lastAt + GAP_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastAt = Date.now();
}

async function upload(full, rel) {
const ext = path.extname(full).slice(1).toLowerCase();
if (!BINARY.has(ext) && !TEXT.has(ext)) return;

const isBinary = BINARY.has(ext);
const content = isBinary
? fs.readFileSync(full).toString('base64')
: fs.readFileSync(full, 'utf8');

const body = { path: rel, content };
if (isBinary) body.encoding = 'base64';

await rateLimit();
const res = await fetch(`${BASE_URL}/api/v1/files/upload`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});

if (!res.ok) {
console.error(`${rel}: ${res.status} ${await res.text()}`);
errors++;
} else {
console.log(`${rel}`);
}
}

async function walk(dir, base = dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
const rel = path.relative(base, full).replace(/\\/g, '/');
if (entry.isDirectory()) await walk(full, base);
else await upload(full, rel);
}
}

async function main() {
if (!API_KEY) {
console.error('FLEXWEG_API_KEY environment variable is not set');
process.exit(1);
}
if (!fs.existsSync(BUILD_DIR)) {
console.error(`Build directory '${BUILD_DIR}' not found. Run 'npm run build' first.`);
process.exit(1);
}

console.log(`Uploading ${BUILD_DIR}/ to ${BASE_URL} ...\n`);
await walk(BUILD_DIR);

if (errors > 0) {
console.error(`\nFailed to sync ${errors} file(s).`);
process.exit(1);
}
console.log('\nAll files synced successfully.');
}

main();

Step 15 — Create the GitHub Actions workflow

.github/workflows/deploy.yml
name: Deploy to Flexweg

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build site
run: npm run build

- name: Deploy to Flexweg
env:
FLEXWEG_API_KEY: ${{ secrets.FLEXWEG_API_KEY }}
run: node sync-flexweg.js

Step 16 — Create a .gitignore

.gitignore
node_modules/
_site/
.DS_Store
.env
.env.local

Step 17 — Note your Flexweg URL and API key

  1. Log into flexweg.com. Your site URL is shown at the top, e.g. https://4dp6g1d6.flexweg.com. You can customize the subdomain to something like blog-yourname (see Publish your first page).
  2. Open Account → API and copy your API key.

Step 18 — Push to GitHub

You need the dev server stopped before running git commands in a new terminal, or open a second terminal while docker compose up keeps running.

Stop compose with Ctrl+C, then:

git init -b main
git add .
git commit -m "Initial blog with Eleventy + Decap CMS"

Create an empty repository on github.com/new (name it my-blog, no README or license). Then push:

git remote add origin https://github.com/YOUR_USERNAME/my-blog.git
git push -u origin main

Step 19 — Add the Flexweg API key as a GitHub secret

  1. On GitHub, open your repo → Settings → Secrets and variables → Actions.
  2. Click New repository secret.
  3. Name: FLEXWEG_API_KEY. Secret: paste the key from Step 17.
  4. Click Add secret.

Step 20 — First deployment

The workflow runs on every push to main. Trigger it by pushing any change:

# Add a post through the CMS or edit docs, then:
git add -A
git commit -m "First deploy"
git push

Go to your GitHub repo → Actions tab → watch the workflow run. When it turns green (~2 minutes, most of it spent in the rate-limited upload), open your Flexweg URL — your blog is live.

Step 21 — Daily workflow

  1. docker compose up — start the dev environment.
  2. Write articles in the admin at http://localhost:8080/admin/ or directly in src/posts/*.md.
  3. Preview at http://localhost:8080.
  4. When ready: git add -A && git commit -m "New post" && git push.
  5. GitHub Actions rebuilds and redeploys. Refresh your live URL.

Troubleshooting

The admin login button does nothing

Make sure the cms container is running (docker compose ps). You should see two services up. If cms is down, check logs with docker compose logs cms.

"Failed to persist entry" when saving in the admin

Check that the cms container has write access to your project folder. On Linux, that usually means the Docker-mounted folder isn't read-only.

11ty says "Template render error" after a CMS save

Decap wrote a file with an unexpected shape (e.g. unusual frontmatter). Open the file under src/posts/ and fix the frontmatter format — 11ty's error message tells you which file.

Workflow fails with "Storage limit exceeded"

A blog with images grows fast past the free plan's 2 MB limit. Upgrade your Flexweg plan at Account → Billing.

Images uploaded through Decap aren't on the live site

Decap writes them to src/static/uploads/, which is committed to git. Make sure you git add src/static/uploads/* before pushing — Decap doesn't git-add for you in the local backend mode.

Next steps

  • Edit articles directly on GitHub — switch the Decap backend from git-gateway to github. Team members get the admin UI at your-subdomain.flexweg.com/admin and edits commit straight to the repo via GitHub OAuth. Requires setting up a GitHub OAuth app and a small OAuth proxy.
  • Add categories and tags — extend src/admin/config.yml with a select or list widget, then filter collections.posts by tag in your homepage template.
  • Generate an RSS feed — create src/feed.njk that loops over collections.posts and emits Atom XML (search "Eleventy RSS" for a minimal template).
  • Customize the theme — everything lives in src/style.css and src/_includes/base.njk. Drop in Tailwind, Bootstrap, or handwrite it.
  • Add an RSS / sitemap — 11ty has first-party plugins for both.

You now have a real, editable blog on Flexweg — with a CMS that your non-technical teammates can actually use.