Lessons Learned: Building and Debugging a .NET 10 Blog on Linux/Nginx

Scott Walker · Sep 16, 2026
Lessons Learned: Building and Debugging a .NET 10 Blog on Linux/Nginx

Production Issues, SEO, and User Management – Journey Log

Over the past few days, I tackled a series of production issues on my .NET 10 Razor Pages blog while simultaneously adding SEO enhancements and user management features. What started as mysterious upload failures evolved into a deep dive into nginx configuration, deployment automation, Entity Framework Core migrations, and full-stack feature development. This post documents those challenges, solutions, and the lessons I learned along the way.

The Journey: From Debugging to Feature Development

Part 1: The HTTP 413 Problem (Uploads Failing on Production)

The Issue: Uploads worked perfectly in development but failed on the Linux production server with cryptic HTTP 413 errors.

Root Cause Analysis

  1. nginx Client Body Limit – The web server was rejecting requests larger than the default 1MB.
  2. ASP.NET Core Kestrel Limits – The application server had its own request body size limits.
  3. HTML Error Pages to JSON Clients – The errors were being returned as HTML instead of JSON to API clients.

Solutions Implemented


server {
    # ... other directives ...
    client_max_body_size 100M;  # Allow up to 100MB uploads
}
            

builder.Services.Configure(options =>
{
    options.MultipartBodyLengthLimit = 50 * 1024 * 1024; // 100MB
});
            

Key Takeaway: When debugging web server issues, you need to check every layer of the stack – from the reverse proxy (nginx) to the application server (Kestrel) to the runtime (.NET Core).


Part 2: Static Assets Vanishing on Linux

The Issue: CSS, JavaScript, and uploaded images didn't display on the production Linux server, even though the same files worked fine locally on Windows.

The Real Problem: Deployment Automation


# Only sync what changed, preserve critical directories
rsync -avz --delete \
  --exclude='appsettings.json' \
  --exclude='wwwroot/img' \
  publish/ scott@scottwalker.me:/var/www/scottwalker/
            

- name: Fix Permissions
  run: |
    ssh -i ${{ secrets.DEPLOY_SSH_KEY }} -p 2222 scott@scottwalker.me << 'EOF'
      sudo chown -R www-data:www-data /var/www/scottwalker
      sudo find /var/www/scottwalker -type d -exec chmod 755 {} \;
      sudo find /var/www/scottwalker -type f -exec chmod 644 {} \;
      sudo chmod 755 /var/www/scottwalker/scottwalker /var/www/scottwalker/*.dll
      sudo systemctl daemon-reload
      sudo systemctl restart scottwalker.service
    EOF
            

Key Takeaway: Automated deployment scripts can cause more damage than they prevent if not carefully designed.


Part 3: Adding Image Upload Management


public async Task UploadImagesAsync(
    IFormFileCollection files, 
    string? subfolder = null)
{
    // Validate and sanitize subfolder
    if (!string.IsNullOrWhiteSpace(subfolder))
    {
        subfolder = SanitizeFolderName(subfolder);
        if (string.IsNullOrWhiteSpace(subfolder))
        {
            result.Errors.Add("Invalid folder name. Use only alphanumeric and underscores.");
            return result;
        }
    }

    // Ensure proper permissions on created folders (Linux-aware)
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
    {
        var dirInfo = new DirectoryInfo(targetDir);
        dirInfo.UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite 
            | UnixFileMode.UserExecute | UnixFileMode.GroupRead 
            | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute 
            | UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
    }
    // ... rest of upload logic
}
            

<div class="card">
    <form id="uploadForm" enctype="multipart/form-data">
        <div class="input-group mb-3">
            <input type="text" id="subfolderInput" placeholder="Optional: folder name" 
                   list="existingFolders" class="form-control" />
            <button class="btn btn-outline-secondary" type="button" id="createFolderBtn">
                New Folder
            </button>
        </div>
        <div class="mb-3">
            <input type="file" id="fileInput" multiple accept="image/*" 
                   class="form-control" />
        </div>
        <button type="submit" class="btn btn-primary">Upload Images</button>
    </form>
</div>
            

Part 4: SEO Enhancements


User-agent: *
Allow: /
Disallow: /Admin/
Disallow: /Account/
Disallow: /api/

User-agent: Googlebot
Disallow: /Admin/
Disallow: /Account/

User-agent: Bingbot
Disallow: /Admin/
Disallow: /Account/

Sitemap: https://scottwalker.me/sitemap.xml
            

[HttpGet("sitemap.xml")]
public async Task Sitemap()
{
    var posts = await db.Posts
        .Where(p => !p.IsPrivate && !p.IsDeleted && p.PublishedAt <= DateTime.UtcNow)
        .OrderByDescending(p => p.PublishedAt)
        .ToListAsync();

    var xml = new StringBuilder();
    xml.AppendLine("");
    xml.AppendLine("");

    xml.AppendLine("");
    xml.AppendLine($"{Request.Scheme}://{Request.Host}/");
    xml.AppendLine("1.0");
    xml.AppendLine("weekly");
    xml.AppendLine("");

    foreach (var post in posts)
    {
        xml.AppendLine("");
        xml.AppendLine($"{Request.Scheme}://{Request.Host}/blog/{post.Slug}");
        xml.AppendLine($"{post.PublishedAt:yyyy-MM-dd}");
        xml.AppendLine("0.8");
        xml.AppendLine("monthly");
        xml.AppendLine("");
    }

    xml.AppendLine("");
    return Content(xml.ToString(), "application/xml");
}
            

public class Post
{
    [MaxLength(160)]
    public string MetaDescription { get; set; } = string.Empty;
}
            

<div class="mb-3">
    <label for="metaDescription" class="form-label">Meta Description (max 160 chars)</label>
    <textarea name="metaDescription" id="metaDescription" class="form-control" 
              maxlength="160" placeholder="Brief description for search results"></textarea>
    <small class="text-muted">This text appears in Google search results</small>
</div>
            

[HttpPost]
public async Task Edit(
    string slug, 
    string title, 
    string? metaDescription = null)
{
    var post = await db.Posts.FirstOrDefaultAsync(p => p.Slug == slug);

    post.Title = title;
    post.MetaDescription = metaDescription ?? string.Empty;

    await db.SaveChangesAsync();
    return RedirectToAction(nameof(Post), new { slug });
}
            

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="@(ViewBag.MetaDescription ?? "A blog about software development, .NET, and web technologies")" />
    <title>@ViewData["Title"] - Scott Walker</title>
</head>
            

Part 5: User Management Enhancements


public class ApplicationUser : IdentityUser
{
    public DateTime? LastSignInUtc { get; set; }
}
            

[HttpPost]
public async Task Login(string email, string password, string? returnUrl = null)
{
    var result = await signInManager.PasswordSignInAsync(
        email, password, isPersistent: true, lockoutOnFailure: false);

    if (result.Succeeded)
    {
        var user = await userManager.FindByEmailAsync(email);
        if (user != null)
        {
            user.LastSignInUtc = DateTime.UtcNow;
            await userManager.UpdateAsync(user);
        }
        return LocalRedirect(returnUrl ?? Url.Action(nameof(BlogController.Index), "Blog")!);
    }
}
            

<table class="table table-striped">
    <thead>
        <tr>
            <th>Email</th>
            <th>Display Name</th>
            <th>Role</th>
            <th>Last Sign-In</th>
            <th>Blocked</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var user in Model)
        {
            <tr>
                <td>@user.User.Email</td>
                <td>@user.DisplayName</td>
                <td><!-- role selection --></td>
                <td>
                    @if (user.User.LastSignInUtc.HasValue)
                    {
                        <time datetime="@user.User.LastSignInUtc.Value.ToString("O")">
                            @user.User.LastSignInUtc.Value.ToString("g")
                        </time>
                    }
                    else
                    {
                        <span class="text-muted">Never</span>
                    }
                </td>
            </tr>
        }
    </tbody>
</table>
            

Architectural Decisions Made

Platform-Aware File Permissions for Uploaded Images

When users upload images on Linux, the files are created under the identity of the web application’s user (in my case, scott). However, nginx serves static files as the www-data user. This means uploaded images must have permissions that allow nginx to read them. Without this, uploads succeed but the images fail to display.

To ensure consistent behavior across Windows (development) and Linux (production), the upload pipeline includes Linux-specific permission handling:


if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
    var fileInfo = new FileInfo(filePath);
    fileInfo.UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite 
        | UnixFileMode.UserExecute | UnixFileMode.GroupRead 
        | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute 
        | UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
}
    

Why this matters:

  • nginx (www-data) must be able to read uploaded images
  • Windows doesn’t require this, so the logic only runs on Linux
  • Prevents “uploaded but not visible” issues in production
  • Ensures the blog behaves consistently across environments
Nginx .NET 10

Comments (0)

Please sign in to comment.