TL;DR
Jellyfin stores everything in SQLite. Metadata, users, activity logs, authentication — all of it lives in .db files that lock under concurrent access. To run multiple replicas, we need a real network-accessible database. This post covers Phase 1 of the HA conversion: forking Jellyfin, designing a pluggable database provider interface, implementing it for PostgreSQL with Npgsql, generating EF Core migrations, writing integration tests with Testcontainers, and building a custom Docker image.
This was the most code-intensive phase and where the AI agent (GitHub Copilot) needed the most manual correction.
Why Fork Jellyfin?
Jellyfin doesn’t have a pluggable database layer. The JellyfinDbContext is hardcoded to use Microsoft.Data.Sqlite. There’s no IDatabaseProvider interface, no configuration option for a different backend. If you want PostgreSQL, you have to modify the source.
This is the core problem that every HA Jellyfin attempt has hit. pseudopseudonym’s approach avoided it entirely by keeping SQLite and failing over the entire storage volume — clever, but limited to one-at-a-time failover. A Hacker News thread on SQLite concurrency debated whether Jellyfin should have used PostgreSQL from the start. As one commenter put it: “this is an article about the hoops the application has to jump through to make SQLite behave well with parallel access. Postgres is designed for parallel access by default.”
The enabler is Jellyfin 10.11’s EF Core refactor. Before that release, Jellyfin used raw Microsoft.Data.Sqlite queries throughout. The migration to Entity Framework Core gave us an abstraction layer we could hook into. Without it, this fork would have been 10x larger.
I forked jellyfin/jellyfin into zolty-mat/jellyfin and created a branch dedicated to the HA changes. The goal: keep the fork minimal so rebasing against upstream releases stays manageable.
What the Fork Changes
The fork adds exactly one new capability: the ability to swap the database backend at startup via configuration. Everything else — the API surface, the media scanning, the transcoding — remains untouched.
Files added or modified:
| File | Change |
|---|---|
Jellyfin.Server.Implementations/IJellyfinDatabaseProvider.cs | New interface |
Jellyfin.Server.Implementations/JellyfinDatabaseProvider.Postgres.cs | PostgreSQL implementation |
Jellyfin.Server.Implementations/JellyfinDbContext.cs | Accept provider via DI |
Jellyfin.Server/Startup.cs | Provider selection from config |
Jellyfin.Server/appsettings.json | New DatabaseProvider config key |
Jellyfin.Data/Migrations/PostgreSql/ | New migration directory |
Directory.Packages.props | Npgsql package version |
Dockerfile | Multi-stage build for HA image |
.github/workflows/ha-build.yml | CI for the fork |
The Provider Interface
The IJellyfinDatabaseProvider interface is intentionally small:
public interface IJellyfinDatabaseProvider
{
/// <summary>
/// Gets the database connection string.
/// </summary>
string ConnectionString { get; }
/// <summary>
/// Configures the DbContextOptionsBuilder for this provider.
/// </summary>
void Configure(DbContextOptionsBuilder builder);
/// <summary>
/// Applies pending migrations to the database.
/// </summary>
Task MigrateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Tests database connectivity.
/// </summary>
Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns the provider name (e.g., "Jellyfin-SQLite", "Jellyfin-PostgreSQL").
/// </summary>
string ProviderName { get; }
}
The interface has 1 property for the connection string, 1 for the provider name, and 3 methods: configure, migrate, and test. This is the minimum surface area needed for the DI container to resolve the correct provider at startup.
A [JellyfinDatabaseProviderKey] attribute tags each implementation, and the provider is selected by the DatabaseProvider key in appsettings.json:
{
"DatabaseProvider": "Jellyfin-PostgreSQL",
"ConnectionStrings": {
"Jellyfin-PostgreSQL": "Host=jellyfin-postgres;Database=jellyfin;Username=jellyfin;Password=..."
}
}
The PostgreSQL Implementation
The PostgreSqlDatabaseProvider class implements the interface using Npgsql.EntityFrameworkCore.PostgreSQL:
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
public class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
{
private readonly string _connectionString;
public PostgreSqlDatabaseProvider(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("Jellyfin-PostgreSQL")
?? throw new InvalidOperationException(
"Connection string 'Jellyfin-PostgreSQL' not found.");
}
public string ConnectionString => _connectionString;
public string ProviderName => "Jellyfin-PostgreSQL";
public void Configure(DbContextOptionsBuilder builder)
{
builder.UseNpgsql(_connectionString, options =>
{
options.CommandTimeout(60);
options.EnableRetryOnFailure(3);
});
}
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
await using var context = CreateContext();
await context.Database.MigrateAsync(cancellationToken);
}
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
await using var context = CreateContext();
return await context.Database.CanConnectAsync(cancellationToken);
}
}
Key Design Choices
EnableRetryOnFailure(3) — PostgreSQL connections can drop during pod rescheduling. Without retry logic, Jellyfin would crash on transient connection failures. EF Core’s built-in retry handles NpgsqlException with IsTransient == true.
CommandTimeout(60) — Library scans can generate large batch queries. The default 30-second timeout was too short for initial metadata import.
No PgBouncer — for a homelab with <10 concurrent users, EF Core’s built-in connection pooling is sufficient. PgBouncer adds operational complexity (another container, another config) for marginal benefit at this scale.
EF Core Migrations: SQLite vs. PostgreSQL
This is where things got tricky. Jellyfin’s existing migration set targets SQLite. The SQL syntax is different enough between SQLite and PostgreSQL that you can’t just point the same migrations at a different database.
Examples of incompatible SQL:
| Operation | SQLite | PostgreSQL |
|---|---|---|
| Auto-increment PK | INTEGER PRIMARY KEY AUTOINCREMENT | SERIAL or GENERATED ALWAYS AS IDENTITY |
| Boolean storage | INTEGER (0/1) | BOOLEAN |
| DateTime storage | TEXT (ISO 8601 string) | TIMESTAMP WITH TIME ZONE |
| String comparison | Case-insensitive by default | Case-sensitive by default |
| GUID storage | TEXT | UUID |
The solution: generate a separate migration set for PostgreSQL. EF Core supports this via the --output-dir flag:
dotnet ef migrations add InitialPostgreSql \
--project Jellyfin.Server.Implementations \
--startup-project Jellyfin.Server \
--context JellyfinDbContext \
--output-dir Migrations/PostgreSql \
-- --DatabaseProvider Jellyfin-PostgreSQL
This produces a complete initial migration that creates all 29 tables (BaseItems, Users, ActivityLogs, etc.) with PostgreSQL-native column types. The migration lives in Migrations/PostgreSql/ and only runs when the PostgreSQL provider is active.
The 29 DbSets
For reference, here’s what Jellyfin stores in its database — every one of these tables needed to work correctly on PostgreSQL:
| DbSet | Purpose |
|---|---|
BaseItems | All media items (movies, episodes, albums, etc.) |
ItemValues | Tags, genres, studios — key/value pairs per item |
UserData | Per-user playback state (position, played, favorite) |
Users | User accounts and preferences |
Permissions | User permission flags |
Preferences | User preference key/value pairs |
ImageInfos | Image metadata per item |
People | Actors, directors, writers |
Chapters | Chapter markers in video files |
MediaStreams | Audio/video/subtitle stream metadata |
MediaSegments | Intro/credits/preview segments |
AncestorIds | Parent-child hierarchy |
ActivityLogs | User activity history |
CustomItemDisplayPreferences | View customization per user per item |
DisplayPreferences | Global display preferences |
ItemDisplayPreferences | Per-item display preferences |
HomeSection | Custom home screen sections |
AccessSchedules | Parental control schedules |
ApiKeys | API key management |
Devices | Registered client devices |
DeviceOptions | Per-device configuration |
MediaAttachments | Embedded attachments in media |
TrickplayInfos | Trickplay (thumbnail preview) data |
KeyframeData | Keyframe positions for seeking |
UserListItems | Custom user lists |
UserLists | Custom user list metadata |
BaseItemProviders | External provider IDs (TMDB, IMDB, etc.) |
PeopleBaseItemMap | Person-to-item relationships |
BaseItemTrailerTypes | Trailer type metadata |
Integration Testing with Testcontainers
Unit testing a database provider requires a database. Testcontainers spins up a real PostgreSQL instance in Docker for each test run:
public class PostgreSqlDatabaseProviderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
public PostgreSqlDatabaseProviderTests()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("jellyfin_test")
.WithUsername("test")
.WithPassword("test")
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
}
[Fact]
public async Task MigrateAsync_CreatesAllTables()
{
var provider = new PostgreSqlDatabaseProvider(
BuildConfiguration(_container.GetConnectionString()));
await provider.MigrateAsync();
var canConnect = await provider.TestConnectionAsync();
Assert.True(canConnect);
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
The test starts a PostgreSQL container, runs migrations, and verifies all tables exist. CI runs this on every PR to the fork.
The Dockerfile
The HA image is built from a multi-stage Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
-c Release -o /app --no-self-contained
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=build /app /app
WORKDIR /app
ENTRYPOINT ["dotnet", "Jellyfin.Server.dll"]
Built with:
docker buildx build \
--platform linux/amd64 \
--provenance=false \
-t 855878721457.dkr.ecr.us-east-1.amazonaws.com/k3s-homelab/jellyfin-ha:latest \
--push .
Two critical flags:
--platform linux/amd64— the k3s cluster is amd64-only--provenance=false— without this, Docker adds attestation manifests that k3s containerd can’t resolve (a known issue that’s bitten me before and is documented in the cluster operating instructions)
What the AI Got Wrong
This phase had the highest rate of Copilot corrections across the entire project. The AI agent:
Used
Version=in.csproj— Jellyfin uses central package management. All NuGet versions must go inDirectory.Packages.props. Copilot putVersion="8.0.0"directly in the project file, which breaks the build.Generated the wrong migration — Copilot tried to create a PostgreSQL migration by modifying the existing SQLite migration instead of creating a new migration set in a separate directory. The result was a migration that tried to create SQLite-syntax tables on PostgreSQL.
Missed nullable annotations — Jellyfin has
<Nullable>enable</Nullable>everywhere. Copilot’s interface definition was missing?on nullable return types, producing CS8603 warnings (which are treated as errors).Used
Task.Result— banned byBannedSymbols.txt. Copilot used.Resultin one initialization path. Had to change it toawait.
All four issues were caught during diff review by Claude Sonnet 4.6 in VS Code. Total correction time: ~15 minutes. Without the diff review step, these would have been discovered as build failures in CI.
Coming Up Next
Tomorrow: storage architecture and the SQLite-to-PostgreSQL migration — deploying PostgreSQL as a StatefulSet, restructuring Jellyfin’s volumes, and moving years of media metadata to a new database.
Browse the code: The Jellyfin fork with the PostgreSQL provider is public at github.com/zolty-mat/jellyfin. The
IJellyfinDatabaseProviderinterface, Npgsql implementation, and all EF Core migrations are in theJellyfin.Server.Implementationsproject.
Want to try this on a cloud provider? A managed PostgreSQL database eliminates the StatefulSet complexity entirely. DigitalOcean Managed Databases start at $15/month with automatic backups.