Overview
Inkwell is a free, open-source, self-hosted blogging platform built on .NET 10. It is a single ASP.NET Core application that runs on the .NET 10 runtime — no PHP, and no Node to build or run it (the compiled CSS ships in the repository). Install it on any server you control, point a domain at it, and write.
Core concepts
- Deployment mode —
SelfHosted(the default) runs a single-owner install;Cloudserves many blogs from one install, each tenant isolated by its URL slug. Set with theDeploymentModekey. - Layouts — 20 structural templates, including Magazine, Feed, Catalog, Verdict, Grid, Minimal, Neutral, Classic and Modern.
- Presets — 16 colour presets, swappable from the admin panel without touching content.
- Editor — a Quill WYSIWYG editor to compose visually, draft, schedule and publish, or write any post or page in Markdown with a server-rendered preview. Every saved change is kept as a revision you can compare and restore.
- Data access — Dapper with raw SQL on SQL Server. No ORM, and the schema applies itself on startup.
Requirements
| Runtime | Minimum version |
|---|---|
| .NET | .NET 10 SDK (or the ASP.NET Core 10 runtime for hosting) |
| Database | SQL Server 2019+ or SQL Server LocalDB |
| Hosting | IIS on Windows, Docker, or any server that runs ASP.NET Core 10 |
| Reverse proxy | IIS, Caddy (bundled in the Docker Compose stack), or nginx |
Installation
1. Clone the repository
$ git clone https://github.com/marutisoftwaresolutions/inkwell $ cd inkwell
2. Restore packages
$ dotnet restore Blog.slnx
3. Configure (see Configuration)
Copy the example settings file and set your connection string:
$ cp Blog.Web/appsettings.example.json Blog.Web/appsettings.json
4. Run
$ dotnet run --project Blog.Web
There is no separate migration step. On first launch, Inkwell’s MigrationService creates every table and seeds the default roles, and it re-applies any new schema on each startup.
/account/register to claim the Admin account. Registration closes once the site has an account; invite everyone else from Users → Invite.
Quick start
The fastest path to a running instance, once a connection string is set (see Database):
$ git clone https://github.com/marutisoftwaresolutions/inkwell $ cd inkwell $ dotnet run --project Blog.Web
For a complete stack with SQL Server and automatic HTTPS, use Docker Compose instead.
Configuration
Settings live in Blog.Web/appsettings.json, copied from appsettings.example.json. Any key can be overridden with an environment variable using the double-underscore convention: ConnectionStrings:DefaultConnection becomes ConnectionStrings__DefaultConnection.
Configuration reference
| Key | Purpose |
|---|---|
ConnectionStrings:DefaultConnection | Database connection string |
DeploymentMode | SelfHosted (single owner) or Cloud (multi-tenant URL routing) |
CanonicalHost | Enforce HTTPS and a specific domain in production |
DataProtection:KeysPath | Folder for the Data Protection key ring (comment-form tokens, preview links, the Search Console credential). Must survive redeploys and be writable by the app pool (default App_Data/keys); the dashboard warns when it cannot be persisted |
ReverseProxy:TrustForwardedHeaders | Trust X-Forwarded-For / X-Forwarded-Proto. Default false, correct for bare IIS or Kestrel. Set true only when Kestrel is reachable solely through a reverse proxy you control; the Docker Compose stack sets it |
ReCaptcha:SiteKey / SecretKey | Optional Google reCAPTCHA v2 on public forms (skipped if absent) |
Jwt:Key / Issuer / Audience | API sign-in. Jwt:Key must be a random secret of at least 32 bytes (openssl rand -base64 48); while it is empty, short or the example placeholder, the API refuses every token and the dashboard says so. The Desk does not use it |
Jwt:AccessTokenExpiryMinutes | API token lifetime (default 15, 1–60) |
Newsletter:BatchSize | Newsletter copies sent per minute through your SMTP server (default 50, 1–500); lower it if your mail provider limits sending rates |
Backups:Path / Keep | Where whole-site backups are stored, outside the web root (default App_Data/backups), and how many are kept (default 10). A restore reads from the same folder |
Exports:Path | Working folder for WordPress and Ghost exports; each file is deleted when its download finishes (default App_Data/exports) |
Exports:PushBatchSize | Posts and pages a push to WordPress or Ghost sends per minute (default 25, 1–200); lower it if the other site rate-limits |
Exports:PushItemSeconds | How long one item may take during a push (default 50, 1–300); a slower one is retried later |
Exports:PushAllowPrivateTargets | Default false. true lets a push reach http://, localhost and private networks, for testing against a local WordPress or Ghost only. Never set it on a public server |
ResponsiveImages:Backfill | Default true: a background job makes the 480, 800 and 1200 px copies of images already uploaded. false stops the job; new uploads still get their copies |
Multi-tenant setup
Inkwell supports two deployment modes, chosen with a single key:
{
"DeploymentMode": "Cloud"
}
SelfHosted(default) — a single-owner install. The first admin owns the site.Cloud— one install serves many blogs, and each tenant is resolved and isolated by its URL slug.
Layout, Preset and CSS variable overrides are chosen per tenant from the admin panel, not in the settings file.
Database
Inkwell runs on Microsoft SQL Server — SQL Server 2019 or later, or SQL Server LocalDB — through Dapper with raw SQL. It is the only supported database.
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=inkwell;User Id=sa;Password=yourpass;TrustServerCertificate=True;"
}
}
Server=(localdb)\\MSSQLLocalDB;Database=inkwell;Trusted_Connection=True;TrustServerCertificate=True; to run against SQL Server LocalDB with no separate server install.
Schema changes ship as idempotent, dated scripts in DBScripts/ and are applied automatically on startup. To apply them by hand, always pass -f 65001 so NVARCHAR text keeps its encoding:
$ sqlcmd -S <server> -d <db> -U <user> -P <pass> -f 65001 -i "DBScripts\init.sql"
DBScripts/2026-09-15_convert-local-timestamps-to-utc.sql and DBScripts/2026-09-17_convert-remaining-local-timestamps-to-utc.sql once each, with @OffsetMinutes set to the host’s historical offset (0 if it always ran in UTC). Both are no-ops on a second run. Then set DataProtection:KeysPath.
Email (SMTP)
Used for password reset, subscriber confirmation, error alerts, and sending newsletters. SMTP is optional: without it, password reset and error-alert email are simply unavailable and the rest of the site keeps working. Newsletter issues go out in the background in batches of Newsletter:BatchSize per minute, with retries, so a large list never blocks the site. Newsletter sending is available on single-site installs, not in Cloud mode.
| Key | Description |
|---|---|
Smtp:Host | Hostname of your SMTP server |
Smtp:Port | Default 587 |
Smtp:EnableSsl | true recommended |
Smtp:Username / Password | SMTP credentials — use a secret manager in production |
Smtp:FromEmail / FromName | Sender identity |
dotnet user-secrets locally and environment variables or a vault in production.
Layouts & Presets
Inkwell separates structure (Layout) from surface (Preset). Pick one of each from the admin panel. Swap them without a rebuild or a migration.
Layouts
Inkwell ships 20 Layouts: Almanac, Bento, Broadsheet, Catalog, Classic, Feed, Grid, Journal, Magazine, Minimal, Modern, Neutral, Newsletter, Notebook, Reader, Showcase, Stream, Studio, Timeline and Verdict. A few of the most used:
| Name | Best for |
|---|---|
| Magazine | Multi-author publications with drop caps, bylines, departments |
| Verdict | Scored “Top N Best” roundups and buyer’s guides |
| Catalog | A single-column boxed card list for SaaS-style company blogs |
| Grid | Image-led or high-volume blogs — a card-and-tile post index |
| Minimal | Stripped-back single column, no sidebar, no chrome |
| Classic | The traditional reverse-chronological blog of dated entries |
Presets
Inkwell ships 16 colour Presets, and any Preset pairs with any Layout. A dark colour scheme can follow each reader’s device or be forced on from Settings.
Custom themes
Beyond the Layouts and Presets, you can override any CSS variable live from the admin panel; overrides are stored per tenant in the CustomThemeSettings table. There is no drop-in theme folder: a new Layout is a change to the Razor views in the source, so it lives in your fork. See the GitHub repository.
Deployment: Linux / systemd
$ dotnet publish Blog.Web/Blog.Web.csproj -c Release -o /opt/inkwell $ sudo nano /etc/systemd/system/inkwell.service
[Unit] Description=Inkwell Blog [Service] WorkingDirectory=/opt/inkwell ExecStart=/usr/bin/dotnet /opt/inkwell/Blog.Web.dll Restart=always RestartSec=10 User=www-data Environment=ASPNETCORE_ENVIRONMENT=Production [Install] WantedBy=multi-user.target
$ sudo systemctl enable --now inkwell
Deployment: Docker
A Dockerfile is included in the repository root. Build and run against your own SQL Server. The database in the connection string must already exist with DBScripts/init.sql applied (sqlcmd -d blog -f 65001 -i DBScripts/init.sql):
$ docker build -t inkwell . $ docker run -d -p 8080:8080 \ -e ConnectionStrings__DefaultConnection="Server=host;Database=blog;User Id=sa;Password=...;TrustServerCertificate=True;" \ -v inkwell-keys:/data/keys -e DataProtection__KeysPath=/data/keys \ -v inkwell-uploads:/app/wwwroot/uploads \ --name inkwell inkwell
The two volumes matter: /data/keys holds the encryption keys that protect comment tokens and preview links, and /app/wwwroot/uploads holds everything readers and editors upload. Without them, both are lost the next time the container is recreated.
For a complete stack — the app, SQL Server, and automatic HTTPS via Caddy — use docker-compose.yml, also at the repository root:
$ cp .env.example .env # set DOMAIN and MSSQL_SA_PASSWORD $ docker compose up -d
On first boot a one-off db-init container creates the blog database and applies DBScripts/init.sql; re-running it on an existing database changes nothing. The app starts once that succeeds, and once it reports healthy, visiting your domain runs the setup wizard. The app’s own port is never published to the host in this configuration — only Caddy’s 80 and 443 are reachable from outside — so nothing but your own reverse proxy can reach it directly. That is also why the stack can safely set ReverseProxy__TrustForwardedHeaders=true.
docker-compose.yml, uncomment the ports: line under app (8080:8080) and remove the caddy service. Use this for evaluation only, never on a public server.
Every configuration key uses the standard ASP.NET Core environment-variable convention: a double underscore stands in for the colon in appsettings.json, so ConnectionStrings:DefaultConnection becomes ConnectionStrings__DefaultConnection. No appsettings.json is baked into the image.
Deployment: IIS
- Install the .NET 10 Hosting Bundle on the server.
- Publish:
dotnet publish Blog.Web/Blog.Web.csproj -c Release -o C:\inetpub\inkwell - Create a new IIS site pointing to
C:\inetpub\inkwell. - Set the application pool to No Managed Code.
- Ensure the app pool identity has read/write access to the publish directory.
Reverse proxy (nginx)
server {
listen 443 ssl;
server_name essays.example.com;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
localhost, so nothing but nginx can reach it, set ReverseProxy:TrustForwardedHeaders to true; the IP firewall and analytics then see each reader’s real address. Leave it false if Kestrel is reachable any other way.
REST API
/api/v1 serves scripts and headless front ends. Anyone can read published posts (GET /posts, /posts/{slug}), pages (/pages/{slug}), categories and tags, in the shape a reader sees on the page: never review notes, schedules or internal fields.
To write, sign in with POST /api/v1/auth/login (email, password and, with two-factor on, code) for a 15-minute bearer token. Then create, update, publish and delete posts (HTML as content or Markdown as markdown, plus customFields), moderate comments and edit your profile.
- Tokens carry your Desk permissions. A Contributor drafts and edits only their own posts, and publishing needs the publish permission.
- The same checks as the Desk. Publishing runs the structured-data check, saves are kept in revision history, and every write is in the audit trail.
- Accounts are checked on every request, so disabling an account stops its token at once. Errors come back as JSON.
- Off until configured. The API stays off until
Jwt:Keyis set to a real secret (see Configuration).
Security
- No external trackers. Page analytics are stored locally and counted by a self-hosted script without cookies. The optional geo-IP lookup calls ip-api.com and is cached; to verify crawlers, the server fetches the public IP lists that Google, Bing, Apple, OpenAI and Perplexity publish.
- IP anonymisation. Page-view addresses are stored only as an HMAC-SHA256 under a secret generated for each installation, so a copy of the database does not reveal reader addresses.
- Sign-in. Passwords are hashed with PBKDF2-SHA256 (100,000 iterations, constant-time comparison), cookies are HttpOnly and SameSite=Lax, every form carries an anti-forgery token, and two-factor sign-in is available to every user.
- Automatic IP blocking. Exploit scanners and brute-force sign-ins block themselves (Admin → Security). Proxy headers are ignored unless you declare a trusted proxy, so a client cannot forge its address.
- Immutable audit trail. There is no delete or truncate endpoint for the audit log.
- Upload safety. MIME types are validated, and files are organised under
wwwroot/uploads/year/month/.
Jwt:Key from working. See the changelog. Keep the ASP.NET Core 10 runtime on the server patched as well.
Development
$ dotnet watch run --project Blog.Web # hot reload $ dotnet test # xUnit; integration tests skip without SQL Server $ dotnet run --project tools/FeatureImageGenerator # OG / feature images
The public stylesheet is precompiled and committed, so dotnet build needs no Node. Node is only needed to regenerate it after adding Tailwind utility classes (build with -p:BuildTailwindCss=true).
Something missing? Open a discussion on GitHub or contact the team.