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:8080with hot reload - A visual admin panel at
http://localhost:8080/adminpowered 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
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:
- macOS / Linux
- Windows PowerShell
cd ~/Desktop
# or: cd ~/Projects, or any folder you prefer
cd $HOME\Desktop
# or: cd $HOME\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:
{
"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.
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:
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 andstyle.cssare 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:
<!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
---
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/:
{
"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:
---
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
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.
<!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:
# 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:
- http://localhost:8080 — your blog, currently showing "Hello, world".
- http://localhost:8080/admin/ — the Decap CMS admin UI. Click Login (since
local_backend: true, no real authentication happens) and you'll land on the posts list.
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/):
- Click the Blog posts collection in the left sidebar.
- Click New Post in the top right.
- Fill in Title, Date, optionally drop an image in Featured image, and write your article in the Body field (markdown toolbar included).
- 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/.
// 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
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
node_modules/
_site/
.DS_Store
.env
.env.local
Step 17 — Note your Flexweg URL and API key
- 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 likeblog-yourname(see Publish your first page). - 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
- On GitHub, open your repo → Settings → Secrets and variables → Actions.
- Click New repository secret.
- Name:
FLEXWEG_API_KEY. Secret: paste the key from Step 17. - 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
docker compose up— start the dev environment.- Write articles in the admin at http://localhost:8080/admin/ or directly in
src/posts/*.md. - Preview at http://localhost:8080.
- When ready:
git add -A && git commit -m "New post" && git push. - GitHub Actions rebuilds and redeploys. Refresh your live URL.
Troubleshooting
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.
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.
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.
A blog with images grows fast past the free plan's 2 MB limit. Upgrade your Flexweg plan at Account → Billing.
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-gatewayto github. Team members get the admin UI atyour-subdomain.flexweg.com/adminand 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.ymlwith aselectorlistwidget, then filtercollections.postsby tag in your homepage template. - Generate an RSS feed — create
src/feed.njkthat loops overcollections.postsand emits Atom XML (search "Eleventy RSS" for a minimal template). - Customize the theme — everything lives in
src/style.cssandsrc/_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.