A Go CMS framework built for sites that can't afford to go down.
MPLS CMS is a modular Go framework for building content sites. It ships with a plugin system, a schema-driven content layer, a 4-layer template engine, and security defaults that most CMS frameworks treat as optional extras. Server-rendered, no JavaScript build tools, single binary deployment.
Built originally for Mercado Media Productions — an independent journalist covering ICE raids and protests in Minneapolis. It runs in production with 7 first-party plugins.
| WordPress | Ghost | Hugo | PocketBase | MPLS CMS | |
|---|---|---|---|---|---|
| Language | PHP | Node.js | Go | Go | Go |
| Rendering | Server | Server | Static | API-first | Server |
| Database | MySQL | SQLite/MySQL | None | SQLite | PostgreSQL |
| Plugin system | Yes | Limited | No | No | Yes |
| Multi-user admin | Yes | Yes | No | Yes | Yes |
| Security defaults | Plugins | Basic | N/A | Basic | Built-in |
| Single binary | No | No | Yes | Yes | Yes |
WordPress has the ecosystem. Hugo has the speed. Nothing in Go has both a plugin system and a proper editorial CMS. MPLS CMS fills that gap.
Core
- Three-phase module lifecycle: resolve dependency DAG → run plugin migrations → register routes
- Plugin system via optional interfaces — no callbacks, no event bus, just type assertions
- Schema-driven content types: define a
ContentTypein Go, get migration SQL, repository, admin CRUD, and REST API generated automatically - 4-layer template loader: project overrides > theme > plugins > framework defaults
- Theme system with
theme.jsonmanifest, CSS variable injection, FuncMap functions
Auth & Security
- Redis-backed sessions with 32-byte crypto/rand tokens, HttpOnly + Secure + SameSite cookies
- bcrypt cost 12, TOTP 2FA with AES-256-GCM encrypted secrets, QR code enrollment
- CSRF double-submit cookie on all forms and admin API endpoints
- Per-route rate limiting via Redis sliding window
- Strict CSP built from config slices — no hardcoded domains
- Password reset with hashed tokens, 1-hour expiry, anti-enumeration response
Content
- TipTap rich text editor with autosave, media library picker, SERP preview
- Scheduled publishing with background poller
- Slug generation with collision handling
- bluemonday HTML sanitization on all editor output
Infrastructure
- PostgreSQL 16 via pgx/v5 with connection pooling
- Redis 7 for sessions, rate limiting, page cache, SSE fanout
- Cloudflare R2 / S3 file uploads with local disk fallback
- Server-sent events hub with topic-based pub/sub, nil-safe, slow-client eviction
fwCLI for project scaffolding and content type code generation- Docker Compose deployment, systemd service, nginx + Let's Encrypt reference config
go install github.com/mplsllc/cms/cmd/fw@latest
fw new mysite
cd mysite
cp .env.example .env # set DATABASE_URL, REDIS_ADDR, SESSION_SECRET
go run ./cmd/server # opens setup wizard at localhost:8080The setup wizard creates your admin account on first run — no CLI user creation step needed.
Add a content type:
fw content Article title:text body:richtext author:relation:users published_at:datetimeThis generates a migration file, model struct, repository, admin handlers, and templates — wired and ready to register.
Every plugin, theme, and feature is a Module:
type Module interface {
Name() string
Dependencies() []string // topological sort, cycle detection
Register(app *App) error // fatal by default, optional via RegisterOptional
}Plugins implement additional optional interfaces as needed — the framework checks with type assertions, not reflection:
type PluginWithNav interface {
NavItems() []NavItem
}
type PluginWithDashboard interface {
DashboardStats(ctx context.Context) []DashboardStat
}
type PluginWithMigrations interface {
Migrations() []Migration
}
type PluginWithTemplates interface {
TemplateFS() fs.FS
}
type PluginWithBeforeRender interface {
OnBeforeRender(c *gin.Context, name string, data gin.H)
}
type PluginWithPostSaved interface {
OnPostSaved(ctx context.Context, event PostSavedEvent) error
}
type PluginWithPostDeleted interface {
OnPostDeleted(ctx context.Context, postID uuid.UUID) error
}
type PluginWithAdminPostEditLoad interface {
OnAdminPostEditLoad(c *gin.Context, postID uuid.UUID, data gin.H)
}package newsletter
type Plugin struct{ repo *Repository }
func New() *Plugin { return &Plugin{} }
func (p *Plugin) Name() string { return "newsletter" }
func (p *Plugin) Dependencies() []string { return nil }
func (p *Plugin) Register(app *app.App) error {
p.repo = NewRepository(app.Pool())
app.OnBeforeServe(func(a *app.App, r *gin.Engine) {
r.POST("/api/newsletter/subscribe", p.Subscribe)
admin := r.Group("/admin/newsletter", middleware.AuthMiddleware(a.Sessions(), true))
admin.GET("", p.List)
admin.GET("/export", p.Export)
})
return nil
}
func (p *Plugin) NavItems() []app.NavItem {
return []app.NavItem{{Section: "Audience", Label: "Newsletter", URL: "/admin/newsletter"}}
}
func (p *Plugin) Migrations() []app.Migration {
return []app.Migration{{Key: "newsletter:001_create_table", SQL: createTableSQL}}
}
//go:embed all:templates
var templateFS embed.FS
func (p *Plugin) TemplateFS() fs.FS { return templateFS }Register it in one line:
a.RegisterModule(newsletter.New())Templates resolve in priority order. Earlier layers win on name collision:
1. web/templates/ ← project overrides (always wins)
2. themes/active/ ← active theme defaults
3. plugin embed.FS ← plugin-provided templates
4. framework/templates/ ← framework defaults (lowest priority)
A project that wants a custom comment form just drops plugins/comments/widget.html into web/templates/ — no plugin code changes needed.
var Article = schema.ContentType{
Name: "Article",
Table: "articles",
HasSlug: true,
Timestamps: true,
Fields: []schema.Field{
{Name: "Title", Column: "title", Type: schema.FieldText, Required: true, MaxLength: 200},
{Name: "Body", Column: "body", Type: schema.FieldRichText},
{Name: "AuthorID", Column: "author_id", Type: schema.FieldRelation, Required: true},
{Name: "Status", Column: "status", Type: schema.FieldEnum, Enum: []string{"draft", "published"}},
},
}From this definition the framework generates:
CREATE TABLE articles (...)with appropriate column types and constraintsContentRepositorywithGetByID,GetBySlug,List(paginated + filterable),Create,Update,Delete- Admin handlers for list, create, edit, update, delete
- JSON REST API endpoints
- Validation that collects all errors before returning (not short-circuit)
| Plugin | Description | Repo |
|---|---|---|
| cms-newsletter | Email subscriber list with admin export | mplsllc/cms-newsletter |
| cms-seo | Sitemap, robots.txt, meta tags, JSON-LD structured data | mplsllc/cms-seo |
| cms-comments | Threaded comments with moderation queue and ALTCHA anti-spam | mplsllc/cms-comments |
| cms-search | PostgreSQL full-text search with weighted tsvector and snippets | mplsllc/cms-search |
| cms-redirects | 301/302 redirect management with in-memory lookup | mplsllc/cms-redirects |
| cms-analytics | Content performance dashboard (post views, trends, cross-plugin stats) | mplsllc/cms-analytics |
mysite/
cmd/
server/main.go ← config → app → register modules → start
create-admin/ ← CLI to create the initial admin user
internal/
models/ ← app-specific structs
repository/ ← hand-written queries for complex content types
api/
handlers/ ← Gin handlers
router.go ← route registration
plugins/ ← plugins in development before extraction
web/
templates/ ← project overrides (layer 1)
static/ ← CSS, JS, images
themes/
default/ ← active theme (layer 2)
migrations/ ← numbered SQL migration files
.env.example
Makefile
docker-compose.yml
All config via environment variables. Essential ones:
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | required |
REDIS_ADDR |
Redis address | localhost:6379 |
SESSION_SECRET |
32+ byte random string | required |
CSRF_SECRET |
32+ byte random string | required |
TOTP_ENCRYPTION_KEY |
32-byte key for TOTP secret encryption | required |
SITE_NAME |
Site name used in templates and admin | My Site |
APP_BASE_URL |
Full base URL with scheme | http://localhost:8080 |
SMTP_HOST |
SMTP server for outbound email | localhost |
UPLOAD_PATH |
Local file upload directory | ./uploads |
ACTIVE_THEME |
Theme directory name under themes/ |
default |
REDIS_PREFIX |
Prefix for all Redis keys | `` |
Generate secrets:
openssl rand -hex 32 # SESSION_SECRET
openssl rand -hex 32 # CSRF_SECRET
openssl rand -hex 32 # TOTP_ENCRYPTION_KEY- Getting started — from
fw newto deployed site - Content types — schema-driven content modeling
- Themes — building and installing themes
- Plugins — building and publishing plugins
- Security — what ships by default and why
- Deployment — systemd + nginx + Let's Encrypt
- Go 1.24+
- PostgreSQL 14+ (16 recommended —
CREATE OR REPLACE TRIGGERrequires 14+) - Redis 7+
MPLS License — Principled Libre Software. Free to use, modify, and distribute. Cannot be used to build proprietary SaaS products without a commercial license.
MPLS LLC — Minneapolis, MN.
Built for Mercado Media Productions, an independent journalism operation run by US Army veteran Andrew Mercado covering ICE raids, protests, and breaking news in the Twin Cities. The security defaults, plugin isolation, and journalist-specific threat model shaped every architectural decision.