Hello, Ordinary Blog
Having spent a bit of time now working with Ordinary to run my personal blog, I wanted to set up a more formal facility for posting about the framework itself.
Ordinary has always been designed with the idea that it should be just as (or moreso) straightforward to run your own space on the web as it is to set up an ActivityPub account – so, for that reason, I picked to build its blog with the highest barrier-to-entry SSG that also seemed reasonably popular: hi, Hugo.
Context#
I started work on Ordinary in earnest just over a year ago. At a high level, it’s supposed to be a multi-site CMS with room to grow. The initial work has been focused on making it a really solid web-server and serving primarily static content. The longer-term goal is to offer tools incrementally for enhancing and extending the static content with plugin-like actions that can hook into the underlying storage mechanisms and call out to external services.
For this post, we’ll stay very narrowly focused on the Ordinary’s concept of assets (We’ll cover Ordinary’s “native” content management tooling in future posts).
Goal#
Generate a static blog from Markdown files, with an RSS feed, that can and deployed to api.ordinary.host with minimal friction.
Process#
Hugo was new to me. I’ve previously built SSGs with Jekyll, SvelteKit, Nuxt (and some others I won’t mention), but getting installed and started wasn’t too bad once I landed on a theme I liked – terminal by panr.
I already have ordinary CLI and its deps installed; you probably do not. Right now, building from source is the best way to make sure you’re compatible with the latest Ordinary API Server (if you’d like to install and break stuff: instructions).
Hugo (generate)#
After installing the Hugo CLI installed and initializing a project, it took me a bit to tune the terminal theme to get it to a spot I was happy with.
I also had to make a couple of tweaks to get things working with Ordinary by adding a [params].canonical = 'https://oridnary.blog' to the hugo.toml and then set the baseURL to '/' instead of the real domain1.
A quick run of
$ hugo build
will output the generated site to public/. Now, we have something to deploy to somewhere.
Ordinary (build && publish)#
Enter: ordinary ssg init…2
$ ordinary ssg init --help
generate an `ordinary.json` config to deploy your SSG to a running instance of `ordinaryd`
Usage: ordinary ssg init [OPTIONS] <DOMAIN> <DIR_PATH>
Arguments:
<DOMAIN> project domain
<DIR_PATH> assets `dir_path`
Options:
-c, --contacts <CONTACTS>... contacts for Let's Encrypt (auto-TLS)
-e, --error-page include 404.html as error page
-s, --inline-styles accommodate inline styles in the generated files
-r, --inline-scripts accommodate inline scripts in the generated files
-m, --inline-images accommodate inline images in the generated files
-p, --project <PROJECT> project path [default: .]
-v, --verbose... Increase logging verbosity
-q, --quiet... Decrease logging verbosity
-h, --help Print help
-V, --version Print version
Constructing the command for our Hugo project is pretty straightforward; we know that our theme generates a 404.html in the public/ generated output, so we’ll include -e3. We also know that our theme uses inline styles in some places, so we’ll add the -s flag in our command. Because the server we’re deploying to has --insecure disabled, it will use the rustls-acme crate to automatically generate TLS certs with Let’s Encrypt, so we also need to include at least one contact email address to be sent as a part of that process: -c support@ordinarylabs.io. The domain is obviously ordinary.blog and the Hugo generated dir is public so our command looks like this:
$ ordinary ssg init ordinary.blob public -c support@ordinarylabs.io -e -s
And that produces the following ordinary.json file:
// oridnary.json
{
"domain": "ordinary.blob",
"contacts": ["support@ordinarylabs.io"],
"version": "0.1.0",
"storage_size": 10000000,
"port": 4433,
"error": {
"asset": "404.html"
},
"assets": {
"dir_path": "public",
"base_route": "/",
"append_index_html": true,
"html_csp": {
"style_src": "'self' 'unsafe-inline'"
},
"http": {
"cache_control": {
"max_age": 3600
},
"expires": 3600
},
"precompression": ["All"],
"minify_html": true
}
}
Breaking this down:
"domain"tellsordinarydhow to route requests based on the SNI and host headers (if"cnames"were set those would also be used)"contacts"are sent to Let’s Encrypt as a necessary component of getting a cert issued by them"version"is the version of this project’sordinary.jsonand should be incremented when breaking changes are made"storage_size"tellsordinarydto set this project’s LMDBmapsizeto 10mb"port"is which portordinary startwill use when running the project locally"error"."asset"tellsordinarydto use the asset with the name404.htmlwhen serving 400s and 500s (as this is a static site there shouldn’t be any 500s)"assets"."dir_path"tells theordinary publishcommand where to look for assets that need to be written"assets"."base_route"tellsordinarydto response to all requests with that base route by pulling them from assets if there is no other template/action route taking precedence (this uses a{*path}wildcardaxumroute under the hood)"assets"."append_index_html"tellsoridnarydto appendindex.htmlto any routes with no extension or ending in a trailing slash (i.e. both/asdf/and/asdfbecome/asdf/index.html)"assets"."html_csp"."style_src"tellsordinarydto add thestyle-src: 'self' 'unsafe-inline'directive to all.htmlassets that are served, accommodating the inline styles on our theme"assets"."http"allows for the configuration of HTTP caching for theassets; in this case we’ve set theExpiresandCache-Control: max-age=values to 1hr"assets"."precompression"tellordinarydto compress the assets for this project ahead-of-time so that they don’t have to be re-processed on each request to the server–the"All"value means that compressible assets will be compressed withbr,zstd(level 17),deflateandgzip, and stored in those formats so they can be served directly"assets"."minify_html"is set to strip out all whitespace and unneeded quotations from the HTML (minify_jsandminify_cssare also available but as Hugo already performs this operation, it isn’t necessary to do it twice)
Now we’ve got enough to publish out to api.ordinary.host, but we want to make sure that we don’t accidentally publish without running hugo build so we’re going to add a "lifecycle" hook that runs it before build. We also want to clean up some of the extra files that Hugo generates, but we don’t want to publish: **/page/1/index.html.
// orindary.json
// ...
"port": 4433,
"lifecycle": {
"build": {
"before": [
["hugo", "build"],
["sh", "-c", "find public -type d -name 'page' -exec rm -rf {} +"]
]
}
},
// ...
So, in our "lifecycle"."build"."before" we have an array of commands whose subcommands with arguments broken out into subarray of strings (this is primarily to match and cope with Rust’s Command interface, which is being used under the hood to execute these hooks).
Build#
Now that we have our before-build hooks and ordinary.json ready to go, we can run ordinary build:
$ ordinary build -v
2026-05-22T21:11:10.315325Z INFO ordinary:build: building...
2026-05-22T21:11:10.316540Z INFO ordinary:build:lifecycle{when=before name=build}: exec cmd=hugo build
2026-05-22T21:11:10.484185Z INFO ordinary:build:lifecycle{when=before name=build}: success
2026-05-22T21:11:10.484293Z INFO ordinary:build:lifecycle{when=before name=build}: exec cmd=sh -c find public -type d -name 'page' -exec rm -rf {} +
2026-05-22T21:11:10.493637Z INFO ordinary:build:lifecycle{when=before name=build}: success
2026-05-22T21:11:10.493696Z INFO ordinary:build:lifecycle{when=before name=build}: close time.busy=177ms time.idle=25.8µs
2026-05-22T21:11:10.494082Z INFO ordinary:build: exiftool cmd=/Users/seanwatters/.ordinary/bin/exiftool/exiftool -r -all= ./public -directory='.ordinary/gen/%d' --icc_profile:all
2026-05-22T21:11:10.567573Z INFO ordinary:build: exiftool run src=./public dest=.ordinary/gen
2026-05-22T21:11:10.570131Z INFO ordinary:build: asset path=/index.html ext="html" size.source=6.0 kB size.minified=4.5 kB size.reduction=25.48%
2026-05-22T21:11:10.570814Z INFO ordinary:build: asset path=/posts/index.html ext="html" size.source=5.8 kB size.minified=4.3 kB size.reduction=25.48%
2026-05-22T21:11:10.571404Z INFO ordinary:build: asset path=/posts/hello-world/index.html ext="html" size.source=5.7 kB size.minified=4.4 kB size.reduction=22.71%
2026-05-22T21:11:10.571877Z INFO ordinary:build: asset path=/404.html ext="html" size.source=4.7 kB size.minified=3.5 kB size.reduction=24.84%
2026-05-22T21:11:10.572347Z INFO ordinary:build: asset path=/about/index.html ext="html" size.source=4.9 kB size.minified=3.7 kB size.reduction=24.51%
2026-05-22T21:11:10.579838Z INFO ordinary:build: asset path=/tags/index.html ext="html" size.source=4.7 kB size.minified=3.4 kB size.reduction=26.66%
2026-05-22T21:11:10.580554Z INFO ordinary:build: asset path=/tags/intro/index.html ext="html" size.source=5.8 kB size.minified=4.4 kB size.reduction=25.35%
2026-05-22T21:11:10.581050Z INFO ordinary:build: asset path=/categories/index.html ext="html" size.source=4.6 kB size.minified=3.4 kB size.reduction=25.41%
2026-05-22T21:11:10.581144Z INFO ordinary:build: close time.busy=266ms time.idle=38.4µs
2026-05-22T21:11:10.581157Z INFO ordinary: close time.busy=266ms time.idle=3.42µs
Enumerating the steps above:
build:lifecycle{when=before}first executeshugo buildand thensh -c find public -type d -name 'page' -exec rm -rf {} +to clean up the unused filesbuild: exiftool runis stripping the exif data from all assets that can be stripped and moving them to.ordinary/gen/publicbuild: asset path=*is ripping over thepublic/directory to see if there are any assets in need of pre-processing (in this case only the HTML assets have been marked for minification) preprocessed them and then writes them to the.ordinary/gen/publicdir
To verify that everything built correctly we can run the Ordinary Server locally:
$ ordinary start
You can now see it live at http://localhost:4433. If you look through the server logs on startup you’ll get a rough idea of what happens on the receiving ordinaryd instance at publish time.
Publish#
Now that we’ve verified everything builds and runs correctly, we can publish it to api.ordinary.host. Because I already have an account created and am logged in on the CLI, publishing is very straightforward:
$ ordinary publish -v
This operation triggers another build, and will rip over the "assets"."dir_path" and .ordinary/gen/public, writing assets to api.ordinary.host, one by one.
Purpose#
While it’s possible that I will be the only contributor to ordinary.blog, my hope is that it can become a place for people using and engaging with Ordinary’s tools to share what they’re doing and why, so that a community can start to form around sharing ideas about self-hosting/small-hosting4.
Conclusion#
The source code for this blog can be found here and instructions for how to get up and running are in the “Contributing” section. If you have any questions or are curious about Ordinary, feel free to shoot me an email at sean@ordinarylabs.io.
Epilogue#
If you’re wondering to yourself “Wait, why would someone spend a year focused on building out a new framework for Templates, Actions, and Storage, just to make some adjustments to the Assets functionality at the last minute, and make the first project post about hosting the result of someone else’s SSG?” I have another post queued up that will walk through what it took to get an existing Jekyll site translated to “Ordinary Native” (if that’s a thing), and why I see Ordinary actually being more effective as a vehicle for progressive enhancement of existing static sites, than an All New platform you have to convert to.
There are probably reasons not to do this with other themes or in other configurations but with our current setup it doesn’t have a negative impact ↩︎
When I first built it out, I wrote out all of the
ordinary.jsonconfig by hand, but have since added theordinary ssg initconvenience function so that you don’t have to! ↩︎Jekyll, VitePress, and others generate
404.htmlin the same way Hugo does ↩︎people hosting a few sites for their friends or people/projects in their community ↩︎