A lightweight, single-binary content management system. Because 25MB is enough.
G0 CMS is built on a simple premise: modern CMS solutions are bloated.
WordPress requires PHP, MySQL, and prayer. Ghost needs Node.js and 500MB of npm packages. Even "lightweight" solutions demand Docker orchestration.
G0 rejects this. One binary. One config file. One database. Done.
- Single Binary - Everything embedded at compile time (~27MB)
- Zero Dependencies - No PHP, no Node.js, no containers required
- SQLite or PostgreSQL - SQLite for simplicity, Postgres for scale
- Google OAuth - Secure admin access, no password management
- Setup Wizard - First-run configuration UI, no manual config editing
- SEO Built-in - Sitemap, Atom feed, Open Graph tags
- Dark/Light Theme - User preference respected
- Markdown + HTML - Write content your way
# Download the binary (or build from source)
./g0
# Visit http://localhost:8080
# Complete the setup wizard
# Start creating contentgit clone https://github.com/hugopalma17/g0
cd g0
go build -o g0 .
./g0Requires Go 1.22+
G0 uses Google OAuth for admin authentication. You need to create your own credentials:
- Go to Google Cloud Console
- Create a new project (or select existing)
- Note your project name
- Navigate to APIs & Services → OAuth consent screen
- Select External (unless you have Google Workspace)
- Fill in required fields:
- App name:
Your Site Name - User support email:
your@email.com - Developer contact:
your@email.com
- App name:
- Click Save and Continue
- Skip Scopes (we only need basic profile)
- Add your email as a Test user (required while in testing mode)
- Save
- Navigate to APIs & Services → Credentials
- Click + CREATE CREDENTIALS → OAuth client ID
- Application type: Web application
- Name:
G0 CMS - Authorized redirect URIs - Add your callback URL:
For local development:
https://yourdomain.com/api/auth/callbackhttp://localhost:8080/api/auth/callback - Click Create
- Copy your Client ID and Client Secret
When you run G0 for the first time, enter:
- Admin Email: Must match a Google account (the one you added as test user)
- Client ID:
xxxx.apps.googleusercontent.com - Client Secret:
GOCSPX-xxxx
Important: Your redirect URI in Google Console must exactly match your site URL +
/api/auth/callback
While in "Testing" mode, only emails you've added as test users can log in. To allow any email in your allowed_emails config:
- Go to OAuth consent screen
- Click PUBLISH APP
- Complete verification if required (for public apps)
For personal/private sites, keeping it in Testing mode and adding your email as a test user is sufficient.
On first run, G0 presents a setup wizard. All persistent data is stored in ./data/:
data/
├── config.yaml # Configuration (created by wizard)
└── g0.db # SQLite database
Configuration example (data/config.yaml):
site:
name: "My Site"
url: "https://example.com"
tagline: "A site built with G0"
author: "Your Name"
database:
driver: "sqlite" # or "postgres"
path: "./data/g0.db" # SQLite path
auth:
provider: "google"
client_id: "xxx.apps.googleusercontent.com"
client_secret: "xxx"
redirect_url: "https://example.com/api/auth/callback"
allowed_emails:
- "admin@example.com"
session_key: "" # Auto-generated if empty
server:
host: "0.0.0.0"
port: "8080"
admin_path: "/admin" # Custom admin URL| Route | Description |
|---|---|
/ |
Homepage |
/journal |
Blog listing |
/journal/{slug} |
Blog post |
/resume |
Resume page |
/contact |
Contact form |
/admin (customizable) |
Admin panel (OAuth protected) |
/sitemap.xml |
Dynamic sitemap |
/feed.atom |
Atom feed |
Access your admin panel (default /admin, customizable in setup) to:
- Create/edit/delete content
- Manage blog posts, pages, and sections
- Edit SEO metadata
- Access database manager
- Use built-in terminal (for server access)
| Type | Purpose |
|---|---|
blog |
Journal/blog entries |
home |
Homepage content |
resume |
Resume page |
contact |
Contact page content |
./g0
# Runs on :8080 by default[Unit]
Description=G0 CMS
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/g0
ExecStart=/opt/g0/g0
Restart=always
[Install]
WantedBy=multi-user.targetdocker build -t g0 .
docker run -p 8080:8080 -v $(pwd)/data:/app/data g0example.com {
reverse_proxy localhost:8080
}
| Layer | Technology |
|---|---|
| Language | Go |
| Database | SQLite / PostgreSQL |
| Templates | Go html/template |
| Interactivity | htmx + Vanilla JS |
| Styling | Tailwind (compiled) |
| Auth | Google OAuth OIDC |
- Core CMS functionality
- Setup wizard
- SQLite/PostgreSQL support
- Google OAuth admin
- SEO (sitemap, feeds, OG tags)
- Dark/light theme
- Admin panel editor rewrite (proper WYSIWYG + iframe preview)
- Theme system (runtime theme loading)
- Plugin architecture
- Image upload/management
- Multiple admin users
- Content versioning
- One-click deploy templates
g0/
├── internal/
│ ├── auth/ # OAuth + session auth
│ ├── handlers/ # HTTP routes + handlers
│ ├── middleware/ # Host/track placeholders
│ ├── services/ # DB + services
│ └── utils/ # Helpers
├── main.go # Core server
├── config.yaml # Runtime config (created by wizard)
├── go.mod
├── go.sum
├── static/
│ ├── css/ # Stylesheets
│ ├── js/ # JavaScript
│ ├── fonts/ # Web fonts
│ └── images/ # Static images
├── templates/
│ ├── admin.html # Admin panel
│ └── themes/
│ └── default/
│ ├── index.html # Base layout
│ ├── home.html # Homepage
│ ├── journal.html # Blog template
│ ├── resume.html # Resume template
│ ├── contact.html # Contact template
│ ├── 404.html # Error page
│ └── static/ # Theme assets (css, js, images)
└── data/
└── g0.db # SQLite database (runtime)
G0 renders every page through templates/themes/default/index.html, which defines a {{block "content" .}} section. Each page template only needs to define that block.
- Create a new template, for example
templates/themes/default/about.html:
{{define "content"}}
<section class="max-w-4xl">
<div class="prose max-w-none">
<h2>About</h2>
<p>Write your content here.</p>
</div>
</section>
{{end}}- Add a route in
internal/handlers/routes.goby copying thehome/resume/contactcases and pointing to your new template. - Create content in the admin panel with the matching type (for example
about).
The base layout, navigation, and footer stay in index.html; your page body lives in its own template.
Use this workflow to add a new page type end-to-end. The base layout lives in templates/themes/default/index.html, and each page only provides the {{define "content"}} block.
- Create the page body in
templates/themes/default/<page>.html:
{{define "content"}}
<section class="max-w-4xl">
{{if .Content}}
<div class="prose max-w-none">
{{.Content}}
</div>
{{else}}
<div class="prose max-w-none">
<h2>About</h2>
<p>Write your content here.</p>
</div>
{{end}}
</section>
{{end}}- Register the route in
internal/handlers/routes.goinside theswitch viewblock:
case "about":
targetTemplate = "templates/themes/default/about.html"
for i := range entries {
if entries[i].Type == "about" {
matchedEntry = &entries[i]
break
}
}-
Create content in the admin panel with
type = about. That content becomes{{.Content}}on the page. -
If the page should appear in the navigation, add a link in
templates/themes/default/index.html. -
If you want SEO tags for this page, add JSON metadata in the admin panel (Metadata field). The layout reads it and fills
{{.SeoDesc}},{{.SeoImage}}, and{{.SeoKeywords}}.
{{.Content}}HTML content for the page{{.Title}}page title{{.Site.Name}},{{.Site.Tagline}},{{.Site.Author}}{{.SeoDesc}},{{.SeoImage}},{{.SeoKeywords}}{{.AdminPath}},{{.Theme}},{{.Year}}
Add JSON metadata in the admin panel (Metadata field):
{"title": "About | My Site", "description": "Short summary", "og_image": "/static/images/og-image.jpg", "keywords": "about, company"}How it works:
- The JSON above is stored in the database in the
content.metadatacolumn for that page. - On render, the server parses that JSON and maps it to
Title,SeoDesc,SeoImage, andSeoKeywordsfor the template. og:titleusesmetadata.titleif present, otherwise the entry title, otherwise the site name.og:descriptionusesmetadata.descriptionif present, otherwisesite.descriptionfromconfig.yaml.og:imageusesmetadata.og_imageif present, otherwisesite.url + /static/images/og-image.jpg. Setsite.urlif you want absolute OG URLs.
Optional auto-generation:
- If you add a metadata generation script such as
generatemetadate.js, you must install and configure a provider (Copilot, Gemini, or similar) before running it. This repo does not ship that dependency by default.
- OAuth tokens never stored, session-based auth
- Config file written with 0600 permissions
- Admin JS files protected behind auth
- No directory listing on static files
- SQL injection prevention via parameterized queries
Your callback URL in Google Console doesn't match your site. Ensure:
- Google Console has:
https://yourdomain.com/api/auth/callback - Matches exactly (http vs https, trailing slashes matter)
Your OAuth app is in testing mode. Either:
- Add your email as a test user in Google Console
- Or publish the app (requires verification for public use)
The Google account you logged in with isn't in the allowed_emails list in config.yaml.
- SQLite: Ensure the
data/directory is writable - PostgreSQL: Check connection details and that the database exists
MIT
G0 CMS: What if your CMS respected your server?