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:

FileChange
Jellyfin.Server.Implementations/IJellyfinDatabaseProvider.csNew interface
Jellyfin.Server.Implementations/JellyfinDatabaseProvider.Postgres.csPostgreSQL implementation
Jellyfin.Server.Implementations/JellyfinDbContext.csAccept provider via DI
Jellyfin.Server/Startup.csProvider selection from config
Jellyfin.Server/appsettings.jsonNew DatabaseProvider config key
Jellyfin.Data/Migrations/PostgreSql/New migration directory
Directory.Packages.propsNpgsql package version
DockerfileMulti-stage build for HA image
.github/workflows/ha-build.ymlCI 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:

OperationSQLitePostgreSQL
Auto-increment PKINTEGER PRIMARY KEY AUTOINCREMENTSERIAL or GENERATED ALWAYS AS IDENTITY
Boolean storageINTEGER (0/1)BOOLEAN
DateTime storageTEXT (ISO 8601 string)TIMESTAMP WITH TIME ZONE
String comparisonCase-insensitive by defaultCase-sensitive by default
GUID storageTEXTUUID

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:

DbSetPurpose
BaseItemsAll media items (movies, episodes, albums, etc.)
ItemValuesTags, genres, studios — key/value pairs per item
UserDataPer-user playback state (position, played, favorite)
UsersUser accounts and preferences
PermissionsUser permission flags
PreferencesUser preference key/value pairs
ImageInfosImage metadata per item
PeopleActors, directors, writers
ChaptersChapter markers in video files
MediaStreamsAudio/video/subtitle stream metadata
MediaSegmentsIntro/credits/preview segments
AncestorIdsParent-child hierarchy
ActivityLogsUser activity history
CustomItemDisplayPreferencesView customization per user per item
DisplayPreferencesGlobal display preferences
ItemDisplayPreferencesPer-item display preferences
HomeSectionCustom home screen sections
AccessSchedulesParental control schedules
ApiKeysAPI key management
DevicesRegistered client devices
DeviceOptionsPer-device configuration
MediaAttachmentsEmbedded attachments in media
TrickplayInfosTrickplay (thumbnail preview) data
KeyframeDataKeyframe positions for seeking
UserListItemsCustom user lists
UserListsCustom user list metadata
BaseItemProvidersExternal provider IDs (TMDB, IMDB, etc.)
PeopleBaseItemMapPerson-to-item relationships
BaseItemTrailerTypesTrailer 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:

  1. Used Version= in .csproj — Jellyfin uses central package management. All NuGet versions must go in Directory.Packages.props. Copilot put Version="8.0.0" directly in the project file, which breaks the build.

  2. 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.

  3. 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).

  4. Used Task.Result — banned by BannedSymbols.txt. Copilot used .Result in one initialization path. Had to change it to await.

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 IJellyfinDatabaseProvider interface, Npgsql implementation, and all EF Core migrations are in the Jellyfin.Server.Implementations project.

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.