Small deployment, big problem: how a simple form feature can fail in production

A simple post-form feature can expose the whole delivery process: frontend state, API idempotency, Stripe, email, secrets, smoke tests, Azure and rollback. A practical approach to a small release without guesswork.

The full article is available below. Enable JavaScript to use quizzes and simulations.

Production readiness in practice

A small feature can test the entire deployment process.

One modal after a form submission, three amounts and a payment redirect look simple on screen. In production they touch frontend state, the API, idempotency, Stripe, email, secrets, smoke tests, analytics and rollback.

Some features look trivial in a mockup: one modal, three buttons, a short description and a payment redirect. Done.

Production does not judge a feature by how it looks in Figma or locally on localhost. It judges whether the user can finish the action, the backend avoids excessive disclosure, checkout has the right description, email arrives, the new binary is actually running, verification is clear and recovery is possible.

This article examines that kind of problem: a simple post-form feature that can fall apart across frontend, API, configuration, Stripe, email and deployment. It looks small until you understand the production responsibilities involved.

What the feature really involved

Imagine a user submitting a form. After success, an optional screen says, “If you like, you can buy us a coffee for quicker feedback.” This is not a service purchase. It is a voluntary addition that should be clear, tasteful and pressure-free.

On paper, the flow is simple:

  • the form sends a message,
  • the backend stores the contact,
  • the frontend opens a modal,
  • the user can close it or continue to Stripe,
  • Stripe creates a checkout,
  • the system records a trail,
  • the user can return to the website.

It sounds simple, which makes it easy to get wrong.

Frontend

The modal state needs an owner. Otherwise, “close” may change nothing.

Backend

Checkout needs validation and idempotency. Clicking must not create chaos.

Deployment

A new frontend without the new API is half a release. Production needs evidence.

Common mistakes include:

  • a modal that cannot close because its component state is hard-coded,
  • a payment button spinning forever because timeout or API-error handling is missing,
  • checkout creation without idempotency, producing multiple sessions for the same action,
  • Stripe displaying an old description because the frontend was deployed but the API was not,
  • a poor confirmation email or a logo that fails to load in Gmail,
  • analytics missing an important event,
  • a smoke test checking only /healthrather than the user journey,
  • no clarity about which version is actually running after deployment.

This is when a “small feature” stops being small.

First trap: the UI works locally, but its state is not production-ready

The simplest defect is a modal that cannot be closed. The code looks harmless:

<app-coffee-offer-modal [open]="true"></app-coffee-offer-modal>

A small detail can be disastrous in production. If open is fixed, the component can emit closeand clear its internal state correctly, while its parent keeps saying, “The modal must stay open.”

The right direction is simple: state belongs to the process, not to chance.

isCoffeeOpen = false;
lastContactId: string | null = null;

onContactSent(contactId: string): void {
  this.lastContactId = contactId;
  this.isCoffeeOpen = true;
}

closeCoffeeOffer(): void {
  this.isCoffeeOpen = false;
}
<app-coffee-offer-modal
  *ngIf="lastContactId"
  [open]="isCoffeeOpen"
  [sourceId]="lastContactId"
  sourceType="contact"
  (close)="closeCoffeeOffer()">
</app-coffee-offer-modal>

This simple example illustrates a principle: production frontend behavior cannot depend on “it will appear somehow”. State needs an owner, an entry point and an exit point.

Second trap: checkout cannot be an arbitrary redirect

Payment redirection seems easy: click a button, receive a URL from the backend, then the frontend calls window.location.assign(url).

But an empty URL, wrong URL, non-Stripe URL or repeated clicks can create confusion.

private redirectToCheckout(rawUrl: string): boolean {
  try {
    const url = new URL(rawUrl, window.location.origin);
    const isStripeCheckout =
      url.protocol === 'https:' &&
      (url.hostname === 'checkout.stripe.com' || url.hostname.endsWith('.stripe.com'));

    if (!isStripeCheckout) {
      return false;
    }

    window.location.assign(url.toString());
    return true;
  } catch {
    return false;
  }
}

This is not complete payment security, but it is an important hygiene layer. The frontend should not blindly navigate to any URL returned by an API.

Backend: validation, idempotency and a clear description

The backend needs validation, idempotency and an explicit model of permitted actions. This matters particularly for payments, including voluntary ones.

public sealed record CoffeeCheckoutCreateDto(
    string SourceType,
    Guid SourceId,
    int AmountPln,
    string? Email);

private static readonly int[] AllowedAmounts = [15, 25, 50];

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    AppDbContext db,
    IStripeCheckoutFactory stripe,
    CancellationToken ct) =>
{
    if (!AllowedAmounts.Contains(dto.AmountPln))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["amountPln"] = ["Unsupported amount."]
        });
    }

    var sourceType = dto.SourceType.Trim().ToLowerInvariant();
    if (sourceType is not ("contact" or "audit"))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["sourceType"] = ["Unsupported source type."]
        });
    }

    var idempotencyKey = $"{sourceType}:{dto.SourceId}:{dto.AmountPln}";

    var existing = await db.CoffeeCheckouts
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.IdempotencyKey == idempotencyKey, ct);

    if (existing?.StripeSessionUrl is not null)
    {
        return Results.Ok(new { url = existing.StripeSessionUrl });
    }

    var checkout = await stripe.CreateSessionAsync(new StripeCoffeeSessionRequest
    {
        AmountPln = dto.AmountPln,
        Email = dto.Email,
        ProductName = "Coffee for feedback and a quicker response",
        ProductDescription =
            "An optional contribution toward coffee while preparing feedback, reviewing your request and outlining concrete next steps. This does not replace a project quotation.",
        IdempotencyKey = idempotencyKey
    }, ct);

    db.CoffeeCheckouts.Add(new CoffeeCheckout
    {
        Id = Guid.NewGuid(),
        SourceType = sourceType,
        SourceId = dto.SourceId,
        AmountPln = dto.AmountPln,
        IdempotencyKey = idempotencyKey,
        StripeSessionId = checkout.SessionId,
        StripeSessionUrl = checkout.Url,
        Status = "created",
        CreatedAt = DateTimeOffset.UtcNow
    });

    await db.SaveChangesAsync(ct);

    return Results.Ok(new { url = checkout.Url });
});

Three things are essential:

  • amounts come from an allowlist, not arbitrary input,
  • the source is explicit and bounded,
  • the same action does not endlessly create new sessions.

It is a small amount of code with a large production impact.

Azure: configuration outside the repository and feature flags

Locally, it is easy to put a key into appsettings.json, run the project and say it works. That is the wrong production direction.

Stripe keys, webhook secrets, passwords, connection strings and deployment tokens should not live in the repository. Keep them in the environment, a vault or an appropriate configuration service.

A useful Azure combination is:

  • Azure App Configuration for switches and operational settings,
  • Azure Key Vault for secrets,
  • Managed Identity instead of permanent passwords,
  • deployment slots for safer releases.
using Azure.Identity;
using Microsoft.FeatureManagement;

var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential();

var appConfigEndpoint = builder.Configuration["Azure:AppConfigurationEndpoint"];

if (!string.IsNullOrWhiteSpace(appConfigEndpoint))
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(appConfigEndpoint), credential)
            .ConfigureKeyVault(kv => kv.SetCredential(credential))
            .UseFeatureFlags(flagOptions =>
            {
                flagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
            });
    });

    builder.Services.AddAzureAppConfiguration();
    builder.Services.AddFeatureManagement();
}

And use a feature flag:

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    IFeatureManager featureManager,
    CancellationToken ct) =>
{
    if (!await featureManager.IsEnabledAsync("CoffeeCheckout", ct))
    {
        return Results.Problem(
            title: "Feature disabled",
            detail: "Coffee checkout is currently unavailable.",
            statusCode: StatusCodes.Status503ServiceUnavailable);
    }

    // actual checkout logic
    return Results.Ok();
});

If something fails after deployment, the feature can be disabled without immediately rolling back the entire application.

Fourth trap: deploying the frontend is not deploying the whole feature

This feature spans two worlds:

  • the frontend shows the modal and calls the endpoint,
  • the backend creates checkout, sends email and persists state.

Deploy only the frontend and users see the new screen while Stripe may retain the old product description. Deploy only the backend and the logic exists but users never invoke it.

Layer What to check Evidence
Frontend Whether the new modal exists in live assets Build hash, main.*.js, browser test
API Whether the new binary is running Service restart, status and startup log
Configuration Whether secrets come from environment/vault No secrets in the repository; environment settings
Stripe Whether a new session has the correct description A test checkout session without payment
Email Whether the logo and content render A test email in Gmail
Rollback Whether the feature can be disabled A feature flag or previous release

Public health endpoints should be boring

A health endpoint is useful, but its public response should not be an operational report for the Internet.

{
  "status": "OK",
  "database": "connected",
  "stripeMode": "live",
  "mailProvider": "smtp",
  "tenantCrypto": "configured",
  "pilotOwnerEmail": "..."
}

These details may not be secrets, but they hand out reconnaissance information. Keep public health responses uninteresting.

app.MapGet("/health", () => Results.Ok(new { status = "OK" }));

Detailed diagnostics should be available only to administrators or authenticated monitoring.

A smoke test that actually checks something

A post-deployment smoke test does not prove the whole system. It catches common release-breaking issues. Here, it should check the website, health, checkout validation, email assets and the absence of console errors.

public sealed class ProductionSmokeProbe
{
    private readonly HttpClient _http;

    public ProductionSmokeProbe(HttpClient http)
    {
        _http = http;
    }

    public async Task<SmokeProbeResult> RunAsync(Uri origin, CancellationToken ct)
    {
        var health = await _http.GetFromJsonAsync<HealthDto>(
            new Uri(origin, "/health"), ct);

        if (health?.Status != "OK")
        {
            return SmokeProbeResult.Fail("Health endpoint is not OK.");
        }

        using var invalidCheckout = await _http.PostAsJsonAsync(
            new Uri(origin, "/api/v1/coffee/checkout"),
            new { sourceType = "contact", sourceId = Guid.Empty, amountPln = 999 },
            ct);

        if (invalidCheckout.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Checkout accepted invalid amount.");
        }

        using var logo = await _http.GetAsync(new Uri(origin, "/email/logo.png"), ct);
        if (!logo.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Email logo asset is missing.");
        }

        return SmokeProbeResult.Ok();
    }
}

public sealed record HealthDto(string Status);

public sealed record SmokeProbeResult(bool Success, string Message)
{
    public static SmokeProbeResult Ok() => new(true, "Production smoke passed.");
    public static SmokeProbeResult Fail(string message) => new(false, message);
}

An Azure Function as a controlled smoke probe

The same probe can run as an Azure Function, manually after deployment or automatically from the pipeline.

public sealed class ProductionSmokeFunction
{
    private readonly ProductionSmokeProbe _probe;

    public ProductionSmokeFunction(ProductionSmokeProbe probe)
    {
        _probe = probe;
    }

    [Function("production-smoke")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
        FunctionContext context)
    {
        var result = await _probe.RunAsync(
            new Uri("https://www.arccloudstudio.com"),
            context.CancellationToken);

        var response = req.CreateResponse(result.Success
            ? HttpStatusCode.OK
            : HttpStatusCode.InternalServerError);

        await response.WriteAsJsonAsync(result, context.CancellationToken);
        return response;
    }
}

Important: it should not make a real payment. It checks contracts and the flow's most important safeguards.

Rollback for a small feature

Small features often receive no rollback plan. That is a mistake.

Rollback need not reverse the entire release. Sometimes it is enough to:

  • disable a feature flag,
  • hide the modal in the frontend,
  • block checkout creation,
  • restore the previous product description,
  • switch to manual handling.
public enum ReleaseDecision
{
    Continue,
    DisableFeature,
    RollbackApi,
    RollbackFrontend,
    ManualHandling
}

public static ReleaseDecision Decide(SmokeProbeResult smoke, int errorRate, bool paymentImpact)
{
    if (!smoke.Success && paymentImpact)
    {
        return ReleaseDecision.DisableFeature;
    }

    if (errorRate > 5)
    {
        return ReleaseDecision.RollbackApi;
    }

    return ReleaseDecision.Continue;
}

Azure App Service: deployment slots as a safety buffer

For an application on Azure App Service, a deployment slot is a useful pattern. Publish the API to the stagingslot rather than directly to production, run smoke tests and only then swap.

az webapp deployment slot create \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging

az webapp deploy \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --src-path ./publish.zip \
  --type zip

curl -fsS https://arccloud-api-staging.azurewebsites.net/health

az webapp deployment slot swap \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --target-slot production

The value of a slot is that release stops being a single leap into the unknown.

What the customer experiences

Customers do not see idempotency, Key Vault or feature flags. They do not care whether the backend uses slots or systemd on a VPS.

They notice simpler things:

  • I clicked, and it worked,
  • I closed it, and the modal disappeared,
  • I paid or skipped without getting stuck,
  • I received a clear message,
  • the website did not feel like a test experiment.

The technology exists to make that simple outcome predictable.

Pre-deployment checklist

If several answers are “I do not know”, the feature is not ready.

  • Does the modal have a state owner, and can it close?
  • Does the API validate input rather than trust the frontend?
  • Is checkout idempotent?
  • Does Stripe accurately explain what the payment is for?
  • Do public endpoints avoid exposing backend details?
  • Are secrets outside the repository?
  • Does the email look good in Gmail?
  • Are assets lightweight and accessible?
  • Does GA4 capture the events that matter?
  • Do you have evidence that frontend and API versions match after release?
  • Can the feature be disabled without taking down the whole system?

Summary

Deceptive production problems often begin with something small: a modal, redirect, Stripe description, email, flag or endpoint.

Without a sound process, a feature can remain half deployed: new frontend, old API, unclear checkout, an unclosable modal and a team unsure what is actually in production.

A well-executed deployment is more than build passed. It explains what changed, where the evidence is, how effects are measured, what happens on failure and how quickly stability can be restored.

That is why a simple feature can be the best test of genuine production readiness.