Skip to content
Documentation

Everything you need to run Inkwell.

Guides, configuration reference, and deployment recipes — all in one place. Inkwell is simple by design; the docs stay short by policy.

Use Ctrl+F to search within this page

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.

Philosophy. Inkwell is deliberately small. It ships the things you actually need and almost nothing else. If a feature isn't here, that's usually intentional.

Core concepts

  • Deployment mode — SelfHosted (the default) runs a single-owner install; Cloud serves many blogs from one install, each tenant isolated by its URL slug. Set with the DeploymentMode key.
  • 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

RuntimeMinimum version
.NET.NET 10 SDK (or the ASP.NET Core 10 runtime for hosting)
DatabaseSQL Server 2019+ or SQL Server LocalDB
HostingIIS on Windows, Docker, or any server that runs ASP.NET Core 10
Reverse proxyIIS, 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.

First-run setup. Once the app is running, visit /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

KeyPurpose
ConnectionStrings:DefaultConnectionDatabase connection string
DeploymentModeSelfHosted (single owner) or Cloud (multi-tenant URL routing)
CanonicalHostEnforce HTTPS and a specific domain in production
DataProtection:KeysPathFolder 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:TrustForwardedHeadersTrust 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 / SecretKeyOptional Google reCAPTCHA v2 on public forms (skipped if absent)
Jwt:Key / Issuer / AudienceAPI 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:AccessTokenExpiryMinutesAPI token lifetime (default 15, 1–60)
Newsletter:BatchSizeNewsletter copies sent per minute through your SMTP server (default 50, 1–500); lower it if your mail provider limits sending rates
Backups:Path / KeepWhere 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:PathWorking folder for WordPress and Ghost exports; each file is deleted when its download finishes (default App_Data/exports)
Exports:PushBatchSizePosts and pages a push to WordPress or Ghost sends per minute (default 25, 1–200); lower it if the other site rate-limits
Exports:PushItemSecondsHow long one item may take during a push (default 50, 1–300); a slower one is retried later
Exports:PushAllowPrivateTargetsDefault 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:BackfillDefault 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;"
  }
}
Local development. Use 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"
Upgrading from before 1.0.6? Timestamps moved from host-local time to UTC. After the dated schema scripts, run 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.

KeyDescription
Smtp:HostHostname of your SMTP server
Smtp:PortDefault 587
Smtp:EnableSsltrue recommended
Smtp:Username / PasswordSMTP credentials — use a secret manager in production
Smtp:FromEmail / FromNameSender identity
Never commit credentials. Use dotnet user-secrets locally and environment variables or a vault in production.
Before your first newsletter, set up SPF, DKIM and DMARC for the sending domain, or issues are likely to land in spam. Send yourself a test from Admin → Newsletter first.

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:

NameBest for
MagazineMulti-author publications with drop caps, bylines, departments
VerdictScored “Top N Best” roundups and buyer’s guides
CatalogA single-column boxed card list for SaaS-style company blogs
GridImage-led or high-volume blogs — a card-and-tile post index
MinimalStripped-back single column, no sidebar, no chrome
ClassicThe 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.

Trying it locally, without a domain? In 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

  1. Install the .NET 10 Hosting Bundle on the server.
  2. Publish: dotnet publish Blog.Web/Blog.Web.csproj -c Release -o C:\inetpub\inkwell
  3. Create a new IIS site pointing to C:\inetpub\inkwell.
  4. Set the application pool to No Managed Code.
  5. 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;
    }
}
Real client addresses. Behind nginx, Inkwell sees the proxy’s address unless it trusts the forwarded headers. If Kestrel listens only on 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:Key is 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/.
Run v1.0.9 or later. v1.0.9 closed sign-up once a site has its first account, made API tokens follow Desk permissions and be checked on every request, ended the Desk session of a disabled or deleted user, and stopped the example 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).

Common questions

Frequently asked about setup.

What .NET version does Inkwell require?
Inkwell requires .NET 10. The SDK is needed for building and publishing; the runtime alone is sufficient for hosting. Any server running .NET 10 on Linux, Windows Server, or macOS can host Inkwell without additional dependencies.
Which databases does Inkwell support?
Inkwell uses Microsoft SQL Server 2019 or later for production, with SQL Server LocalDB ideal for development and single-author setups. The database connection is set with ConnectionStrings:DefaultConnection in appsettings.json, data access uses Dapper with raw SQL, and the schema applies itself on startup.
How do I upgrade Inkwell to a newer version?
Pull the latest source from GitHub, rebuild with dotnet publish, and redeploy the output. There is no manual migration step: MigrationService applies any new schema automatically on startup. Always read the release notes first, because some upgrades carry a one-time step, such as the UTC timestamp conversion scripts introduced in v1.0.6.
How do I add a second blog to an existing Inkwell install?
Set DeploymentMode to Cloud and one install serves many blogs, each tenant resolved and isolated by its URL slug, with its own Layout and Preset chosen in the admin panel. The default, SelfHosted, runs a single-owner install. No second deployment is required.
Can I deploy Inkwell with Docker?
Yes. The repository includes a Dockerfile and a docker-compose.yml with Caddy as a reverse proxy and Microsoft SQL Server (mcr.microsoft.com/mssql/server) as the database. Copy .env.example to .env, set your domain and secrets, then run docker compose up -d from the project root. Compose handles TLS via Caddy's automatic HTTPS.
How do I configure email (SMTP) in Inkwell?
Add your SMTP credentials to the Email section of appsettings.json — Host, Port, Username, Password, and FromAddress. Inkwell uses the .NET MailKit library and supports TLS and STARTTLS. For local development, any SMTP relay or a free Mailtrap.io account works without additional setup.