A self-hosted blogging platform built with Go. Running at https://www.jasonernst.com
- Markdown posts with code syntax highlighting and table support
- Draft / publish workflow
- Post revision history with rollback
- Tags with tag cloud
- Configurable post types (blog posts, notes, etc.)
- Full-text search
- File uploads (images, PDFs, etc.)
- Internal and external backlink tracking
- Comments with markdown support, spam honeypot, and rate limiting; by default commenters must be logged in (GitHub or email code)
- RSS-ready sitemap generation
- Configurable dynamic pages (writing, research, archives, tags, about, custom)
- Research page fed by Google Scholar or the Semantic Scholar API (
scholarplugin), with on-disk caching and throttle resilience - Archives sorted by year and month
- WordPress-style theme system (
themes/{name}/) - Switch themes from admin settings without restart (hot-reload)
- Two built-in themes:
default(monospace, gray) andminimal(sans-serif, blue accent) - Theme-specific CSS served at
/theme/ - Custom header/footer code injection via settings (for analytics, etc.)
- GitHub OAuth login
- Install wizard for first-time setup
- Admin dashboard with recent comments, and a paginated comments page for moderation
- Configurable settings (site title, subtitle, social URLs, favicon, etc.)
- Post type management
- Page management with hero images/videos
- Plugin system for injecting template data / HTML, scheduled jobs, settings, and whole pages
- Built-in plugins:
analytics,socialicons,scholar(research page; Google Scholar is blocked from most cloud IPs, so set itssourcesetting tosemantic_scholarwhen hosting in a datacenter),directory(the plugin directory that runs goblog.live/plugins; off by default) - Dynamic plugins: drop a
.gofile inplugins/dynamic/— no rebuild (see Plugins)
- SQLite (file-based, zero config), MySQL, or PostgreSQL
- Docker support with tagged releases on Docker Hub
- Configurable trusted proxies for reverse proxy deployments (
TRUSTED_PROXIESenv var) - GitHub Actions CI/CD
go build
./goblogVisit http://localhost:7000 and follow the install wizard.
docker run -p 7000:7000 compscidr/goblog:latestSQLite is the default and needs no setup. To use MySQL or PostgreSQL instead, pick it in the install wizard or set the variables in .env (see template.env):
database=postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432 # default
POSTGRES_USER=goblog
POSTGRES_PASSWORD=...
POSTGRES_DATABASE=goblog
POSTGRES_SSLMODE=disable # default; or require / verify-ca / verify-fullThe schema is created and migrated automatically on startup for all three. There is no built-in tool for moving an existing site between databases.
Set TRUSTED_PROXIES so X-Forwarded-For headers are trusted for client IP resolution:
TRUSTED_PROXIES=172.16.0.0/12 ./goblogOn a fresh install the first GitHub account to complete login becomes the admin. If you pre-populate .env (e.g. from configuration management) and skip the wizard, anyone could win that race. Pin it to your own account by adding either or both of these to .env:
admin_login=your-github-username # case-insensitive
admin_github_id=12345 # numeric id: https://api.github.com/users/your-github-usernameOther accounts can still log in as regular users but are never promoted. Leave both unset to keep the first-to-login behaviour.
The pin above only decides who becomes the first admin. After that, admins are managed from the Users page in the admin area (/admin/users), which lists everyone who has logged in. An existing admin can promote any GitHub user to admin or demote another admin; the last remaining admin can't be demoted, so the site never ends up with none. Email-login users can't be made admin (see #565).
To hand the site over to a different GitHub account: log in with the new account once so it appears in the list, promote it from your current admin account, then log in as the new account and demote the old one.
Visitors without a GitHub account can log in with an emailed 6-digit code. Add SMTP details to .env:
smtp_host=smtp.example.com
smtp_port=587 # 465 for implicit TLS; anything else uses STARTTLS when offered
smtp_user=postmaster@example.com # omit for an unauthenticated relay
smtp_password=...
smtp_from=blog@example.comWhen smtp_host and smtp_from are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users are regular users — the admin account is still GitHub-only (see above).
SMTP settings are read once at startup, so restart goblog after changing any smtp_* value in .env for the change to take effect. Go's SMTP client only sends smtp_user/smtp_password over an encrypted connection (STARTTLS, or implicit TLS on port 465) unless the host is localhost, so if you need an unencrypted remote relay, use it without credentials.
Comments require a logged-in user by default: the comment form is replaced by a "Log in to leave a comment" link, and a comment is attributed to the account that posted it (the email is always the account's; the name defaults to the GitHub name or the email's local part but can be edited per comment). Since email login is the way most readers will get an account, configure SMTP as above. If you would rather allow anonymous comments — for example on a site with GitHub login only — untick comments_require_login on the admin settings page.
Themes live in themes/{name}/ with this structure:
themes/
default/
templates/ # HTML templates
static/ # CSS and assets (served at /theme/)
minimal/
templates/
static/
To create a custom theme:
- Copy
themes/default/tothemes/my-theme/ - Customize templates and CSS
- Set the
themesetting tomy-themein admin settings
A plugin implements the plugin.Plugin interface (plugin/plugin.go). Embed plugin.BasePlugin to get no-op defaults and implement only the hooks you need:
| Hook | What it does |
|---|---|
Name(), DisplayName(), Version() |
Identity. Name() is the unique key used to store the plugin's settings. |
Settings() |
Declares settings. They appear under Admin → Settings grouped by plugin, are stored in plugin_settings, and reach every hook as strings via ctx.Settings. The admin UI renders Type: "textarea" as a textarea and everything else as a single-line text input (there is no file or checkbox widget for plugin settings yet, so store booleans as "true"/"false"). Declare an enabled setting to get the on/off toggle — the registry calls every plugin regardless, so honour ctx.Settings["enabled"] yourself. |
TemplateHead(ctx) / TemplateFooter(ctx) |
Return raw HTML injected into <head> / before </body> on every rendered page. Escape anything that came from settings or the request. |
TemplateData(ctx) |
Returns data made available to templates as .plugins.<name>. |
ScheduledJobs() |
Periodic background jobs (Name, Interval, Run(db, settings)), started at boot. |
Pages() / RenderPage(ctx, pageType) |
Own a page type: it gets a slug, an optional nav entry, and you choose the template and data when it is visited. The plugin also owns everything under its slug: ctx.SubPath is "" for /research, "2024" for /research/2024. Return a template name to render it inside the theme, or write the response yourself (e.g. ctx.GinContext.JSON(...)) and return ""; returning "" without writing anything gives a 404. plugins/scholar is the simplest example, plugins/directory uses sub-paths. |
OnInit(db) |
Runs once at startup, after settings are seeded. |
ctx is a *plugin.HookContext carrying the Gin context, the DB, the plugin's own settings, the template being rendered, and the existing template data. plugins/socialicons is the smallest complete example.
Live in plugins/<name>/ as a normal Go package, and are registered in main():
registry.Register(myplugin.New())They have full access to gin, gorm, and any module dependency, and are part of the release binary. Use this for anything that ships with goblog.
Loaded at startup from plugins/dynamic/*.go by the embedded Yaegi Go interpreter — no rebuild, so they work with the Docker image. Enable with:
ENABLE_DYNAMIC_PLUGINS=true ./goblogA dynamic plugin is a single package main file defining func NewPlugin() plugin.Plugin. Start from the shipped example:
cp plugins/dynamic/hello.go.example plugins/dynamic/hello.go
ENABLE_DYNAMIC_PLUGINS=true ./goblog # every page now ends with a greetingthen edit the message under Admin → Settings → Hello (example).
Limits of the interpreted environment:
- Available imports are the Go standard library and
goblog/plugin(Plugin,BasePlugin,HookContext,SettingDefinition,ScheduledJob,PageDefinition).ginandgormare not available, so the hooks that name their types —TemplateData,ScheduledJobs,OnInit,RenderPage— can't be implemented dynamically; write a compiled-in plugin for those. - A file that fails to load is logged and skipped; the rest still load.
- Dynamic plugins run as ordinary Go code inside the goblog process with stdlib access. Only load files you control;
plugins/dynamic/should be writable by the operator alone.
With Docker, bind-mount the directory and set the flag:
docker run -p 7000:7000 -e ENABLE_DYNAMIC_PLUGINS=true \
-v $PWD/plugins/dynamic:/go/src/github.com/compscidr/goblog/plugins/dynamic \
compscidr/goblog:latestgoblog validate-plugin <file.go> loads a single file through the same interpreter and prints its identity as JSON (exit 1 with the load error on stderr if it fails):
./goblog validate-plugin plugins/dynamic/hello.go.example
# {"name":"hello","display_name":"Hello (example)","version":"1.0.0"}With the Docker image (its entrypoint is a shell command, so override it):
docker run --rm --network none -v "$PWD:/p" --entrypoint /go/src/github.com/compscidr/goblog/goblog \
compscidr/goblog:latest validate-plugin /p/plugin.goThis is what the plugin directory registry runs on every submission.
goblog.live/plugins lists published dynamic plugins; https://goblog.live/plugins/index.json is the same list as JSON (name, version, author, license, download_url, sha256, min_goblog_version). Plugins are individual GitHub repositories with releases; the curated list and the build that produces the index live in goblogplatform/plugins, which also documents how to submit one.
The pages are rendered by the built-in directory plugin, which any goblog can turn on under Admin → Settings → Plugin Directory (enabled = true). It fetches index_url every refresh_minutes, keeps the last good copy if the registry is unreachable, and serves /plugins, /plugins/<name> and /plugins/index.json. Only point index_url at a registry you trust: its README, changelog and release-note HTML is shown as-is.
go test ./...- Gin for HTTP routing and middleware
- GORM for database ORM (SQLite, MySQL support)
- Showdown.js + DOMPurify for client-side markdown rendering
- Bootstrap 5 for UI framework
- Server-side rendered templates with JSON REST API at
/api/v1/