# Operating .NET Apps on Dokploy: Backups, Recovery, and Proxy Safety

> Operate a .NET service on Dokploy safely: trusted proxy headers, what Traefik 404, 502, and 504 errors mean, verified releases, off-host backups, and recovery.

Published: 2026-11-04. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/operate-dotnet-apps-on-dokploy

To operate a .NET app on Dokploy safely, trust forwarded headers only from Traefik, deploy immutable images you can roll back to, send backups off the server and rehearse restoring them, set resource limits, and keep the panel private and updated.
Dokploy makes the first deployment feel like a managed platform, and this guide covers the work that remains after it.

It assumes the panel is installed and DNS already resolves.
If you haven't got that far, start with [**how to deploy a .NET app to a VPS with Dokploy**](https://milanjovanovic.tech/blog/deploy-dotnet-app-to-vps-with-dokploy).

![An HTTPS client request reaches Traefik, which terminates TLS and routes over the shared dokploy-network to the API container on port 8080, while Postgres sits on a private network that the proxy never touches](https://milanjovanovic.tech/blogs/articles/operate-dotnet-apps-on-dokploy/traefik-routing.png)

## What This Guide Assumes

[**Dokploy**](https://dokploy.com) manages Docker workloads and [**Traefik**](https://traefik.io) routing on a server you own.
The baseline here is a working deployment that now needs production operating rules.

## The Production Compose Contract

Dokploy Domains is the recommended routing path.
When you attach a domain in the UI, Dokploy adds the Traefik labels and proxy network to the selected service during deployment.
Use **Preview Compose** to inspect that generated contract before a release.

The manual alternative keeps the routing contract in Git.
It requires the following network and labels:

```yaml
services:
  api:
    image: ghcr.io/you/app-api:${GIT_SHA}
    restart: unless-stopped
    networks:
      - dokploy-network # without a shared network, Traefik returns 504
      - appdb-network # private link to the DB, not exposed to the proxy
    environment:
      # Behind a reverse proxy: honor X-Forwarded-* so the REAL client IP drives the
      # per-IP rate limiter (otherwise every request looks like the proxy and the whole
      # app shares one bucket). Also fixes https redirects.
      Proxy__Enabled: 'true'
      Proxy__KnownProxy: '${TRAEFIK_IP}' # from docker inspect dokploy-traefik
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.api.rule=Host(`api.example.com`)'
      - 'traefik.http.routers.api.entrypoints=websecure'
      - 'traefik.http.routers.api.tls.certresolver=letsencrypt'
      - 'traefik.http.services.api.loadbalancer.server.port=8080'

networks:
  dokploy-network:
    external: true # created by the platform, you join it
  appdb-network:
    driver: bridge
```

The manual path has three rules:

**1. Join the external proxy network.**
Traefik can only route to containers it shares a Docker network with.
Dokploy creates `dokploy-network` at install time.
Your service declares it `external: true` and joins it when you are not using isolated deployments.

Keep your database on a separate private network that the proxy never touches.

**2. Add the Traefik labels.**
The four labels say: route requests for `api.example.com`, on the HTTPS entrypoint, with a Let's Encrypt certificate, to this container's port 8080 (the default ASP.NET Core container port).
Those labels replace separate load balancer and TLS configuration.
(If you have used **YARP**, this is the same reverse-proxy concept turned into declarative labels.)

**3. Do not publish host ports.**
No `ports:` section on the api service.
The proxy reaches the container over the shared network, so a published port would only open a second path directly to your app, without TLS or the proxy's protections.
The only ports open on the host should be 80, 443, and SSH.

## Why Does Every Request Come From the Same IP?

When an ASP.NET Core app first moves behind a reverse proxy, two request values change without any error or warning.

From your app's point of view, every request now arrives over a TCP connection from Traefik.
Unless you tell ASP.NET Core otherwise:

- `HttpContext.Connection.RemoteIpAddress` is the **proxy's** IP, for everyone.
- `Request.Scheme` is `http`, because Traefik terminated the TLS and forwarded plain HTTP internally.

Both produce confusing symptoms:

**Symptom 1: redirects and links downgrade to http.**
Anything that builds absolute URLs from the request (auth redirects, `Location` headers on 201s, OIDC callbacks) emits `http://` URLs even though users are on `https://`.
Browsers then either warn, loop, or get blocked by the OIDC provider's redirect URI validation.

**Symptom 2: your per-IP rate limiter rate-limits everyone as one user.**
If you use [**rate limiting**](https://milanjovanovic.tech/blog/how-to-use-rate-limiting-in-aspnet-core) partitioned by client IP, every request now shares the proxy's IP, so the entire user base shares one bucket.
One curious user with a retry loop can 429 your whole product.
Audit logs, geo lookups, and abuse detection get the same wrong IP.

The proxy already sends the client IP in `X-Forwarded-For` and the original scheme in `X-Forwarded-Proto`.
Your app just has to be told to honor them:

```csharp
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;

// Without this, HttpContext.Connection.RemoteIpAddress is the proxy, and Request.Scheme
// is http even when the user is on https. Both break things (rate limiting, redirects).
var proxyEnabled = builder.Configuration.GetValue<bool>("Proxy:Enabled");

if (proxyEnabled)
{
    var knownProxyText = builder.Configuration["Proxy:KnownProxy"]
        ?? throw new InvalidOperationException(
            "Proxy:KnownProxy must contain Traefik's IP address.");

    if (!IPAddress.TryParse(knownProxyText, out var knownProxy))
    {
        throw new InvalidOperationException(
            "Proxy:KnownProxy must be a valid IP address.");
    }

    builder.Services.Configure<ForwardedHeadersOptions>(options =>
    {
        options.ForwardedHeaders =
            ForwardedHeaders.XForwardedFor |
            ForwardedHeaders.XForwardedProto;

        // Accept forwarded headers only from the Traefik instance.
        options.KnownProxies.Add(knownProxy);
    });
}

var app = builder.Build();

if (proxyEnabled)
{
    // Keep this before auth, redirects, and rate limiting.
    app.UseForwardedHeaders();
}
```

Two rules apply to this snippet.

**`UseForwardedHeaders()` must run early.**
It rewrites `RemoteIpAddress` and `Request.Scheme`, so it has to execute before anything that reads them: HTTPS redirection, authentication, rate limiting.
First in the pipeline is the safe default.

**Trust only Traefik.**
`X-Forwarded-For` is just a header, and any client can send one.
Clearing `KnownNetworks` and `KnownProxies` makes ASP.NET Core accept that header from any direct peer, which Microsoft explicitly advises against.

Read Traefik's address from `docker inspect dokploy-traefik`, set it as `TRAEFIK_IP`, and keep the application's host port unpublished.
Recheck the address after Traefik restarts.
If it changes, ASP.NET Core ignores the forwarded headers, and every client shares one rate-limit bucket again until you update the setting and redeploy.

For an isolated Docker network with a controlled subnet, .NET 10 can instead add that narrow CIDR to `KnownIPNetworks`.
Do not trust the CIDR of a shared `dokploy-network`, because another workload on it could spoof a client IP.

If the same image also runs directly during local development, register and invoke the middleware only when `Proxy__Enabled` is true.
The environment flag in the Compose file does not change behavior by itself, because your startup code has to read it.

## The Rest of the App Config

The remaining wiring is standard, so in brief:

- **Secrets via environment variables.** Dokploy's panel manages per-service env vars, which map straight onto .NET configuration keys (`ConnectionStrings__AppDb`). Details in **environment variables in Docker**.
- **The database** is either a Dokploy-managed Postgres or a service in your compose file on the private network. Either way the connection string points at the service name, not localhost.
- **Migrations**: run them at startup for simple single-instance apps, or build an [**EF Core migration bundle**](https://milanjovanovic.tech/blog/ef-core-migration-bundles) and run it as a deploy step for anything you care about.
- **Health checks**: expose separate liveness and readiness endpoints (see **health checks in ASP.NET Core**). Dokploy's Compose deployments run `docker compose up -d` without waiting for health, so request `/health/ready` yourself after each release.
- **CORS and auth**: your allowed origins and OIDC authority URLs change per environment, so keep them in env vars next to everything else.

## What Do Traefik's 404, 502, and 504 Errors Mean?

You deploy, everything looks green, and the domain returns an error.
The status code tells you which layer failed, so read it before you change anything.
I broke a container in each of these ways behind Traefik v3.6 and recorded the response:

| What is wrong | Traefik returns | What to check |
| --- | --- | --- |
| Nothing | 200 from your app | Nothing to do |
| No router matches the hostname | 404 page not found, immediately | The router rule, the domain on the Domains tab, and DNS |
| The container is marked unhealthy | 404 page not found, immediately | The Docker healthcheck, which removes the container from routing |
| Wrong target port | 502 Bad Gateway, immediately | The port your app listens on inside the container |
| No shared Docker network | 504 Gateway Timeout, after 30 seconds | Whether the service joined dokploy-network |

The second 404 is easy to misread, because the routing configuration is correct and the container is running.
Traefik drops a container from routing when its healthcheck fails.
That's a good reason to point the Docker healthcheck at liveness instead of readiness: with a readiness check, a database outage turns your application's `503` into a confusing `404`.

For the port, modern .NET images listen on 8080 unless you changed `ASPNETCORE_HTTP_PORTS`.

## Build a Release You Can Roll Back

A Git-triggered build is convenient, but rollback is only reliable when the previous artifact is immutable and still available.
Use CI to build the image once, tag it with a commit or release version, and let Dokploy deploy that exact image.
Do not rebuild an old branch during an incident and call it a rollback.

Request the application's readiness endpoint after each release.
Keep the previous image tag through the observation window, and record which environment variables and secrets belong to each release.
Database changes must remain compatible with both versions until the rollback window closes.

If readiness or a smoke test fails, redeploy the previous image and investigate away from production.
Dokploy can do this for you only on Swarm-based Applications, through its [**health-check rollback settings**](https://docs.dokploy.com/docs/core/applications/rollbacks).
The **blue-green deployment guide** covers the same verification sequence when you keep two environments running.

## Backups Need a Restore Path

Back up every piece of state that cannot be recreated from Git:

- consistent database dumps
- persistent volumes containing uploads or application data
- Dokploy configuration and deployment metadata
- the environment-variable inventory, with secret values kept in a separate secret manager

Send backups to storage outside the VPS.
A provider snapshot is useful for disaster recovery, but it does not replace an application-consistent database backup with retention.

Schedule a [**restore drill**](https://milanjovanovic.tech/blog/test-postgres-backup-restore) onto a disposable host.
Verify that the database opens, migrations agree with the deployed image, uploaded files are present, and the application passes its smoke tests.
The drill gives you a real recovery time and exposes missing credentials while the original server still exists.

## Patch and Protect the Host

Use SSH keys, disable password authentication, restrict inbound ports to 80, 443, and the SSH access path, and keep the Dokploy panel behind a VPN when possible.

Apply operating-system security updates continuously and plan reboots instead of postponing them indefinitely.
Upgrade Docker and Dokploy deliberately, with a current backup and a rollback path.

Dokploy needs the same attention as the operating system, because it published dozens of critical advisories in 2026, some of them exploitable without an account.
[**Dokploy vs Coolify**](https://milanjovanovic.tech/blog/dokploy-vs-coolify) lists examples, and the [**deployment checklist**](https://milanjovanovic.tech/blog/vps-deployment-checklist) shows how to confirm from outside that the panel isn't reachable.

## Watch Capacity Before It Fails

A single host couples the API, database, proxy, image cache, and logs to one disk and one memory budget.
Add alerts for disk usage, memory pressure, container restarts, certificate expiry, and failed backups.

Set CPU and memory limits so one leaking service cannot starve the database.
Use Dokploy's image cleanup controls after confirming which images and volumes are still required.
Avoid blind prune commands in automation because an unused-looking volume may be the only copy of important state.

Send uptime checks from outside the VPS.
Local health checks can report green while DNS, TLS, the firewall, or the whole host is unreachable.

## Recovery Checklist

When the service is down, isolate the failed layer in this order:

1. Confirm DNS and TLS reach the expected host.
2. Check whether Traefik has a matching router and can see the container on `dokploy-network`.
3. Check the container health status and application logs.
4. Check disk, memory, and database availability.
5. Roll back to the previous immutable image if the failure began with a release.
6. Restore onto a replacement host if the machine or persistent data is lost.

## Summary

Dokploy can make deployment simple, but a single VPS is still yours to operate.
Trust proxy headers only when the network prevents direct access to the application, deploy immutable images, store backups off the host, rehearse restores, and alert on capacity.

Use a [**private network**](https://milanjovanovic.tech/blog/build-your-own-vpn-with-tailscale) to reach the panel, database, dashboards, and SSH without publishing those services to the internet.
My [**self-hosting course**](https://milanjovanovic.tech/pragmatic-self-hosting) practices this same operating routine on one server.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### What should I back up in a Dokploy installation?

Back up the application database, persistent volumes that contain state, and Dokploy configuration. Send copies to storage outside the VPS and rehearse the restore on a replacement host while the original server still exists.

### Why does my app behind Traefik see every request coming from the same IP?

The TCP connection your app sees belongs to the proxy, so RemoteIpAddress is the proxy address unless you enable ForwardedHeaders middleware and explicitly trust Traefik as the forwarder. Until you do, per-IP rate limiting effectively puts all users in one bucket.

### Why does Traefik return 404 for my deployed container?

Traefik returns 404 when no router matches the request or when the container is marked unhealthy, because it removes unhealthy containers from routing. A wrong target port returns 502 instead, and a container that shares no network with Traefik returns 504 after a timeout.

### Should I publish container ports when running behind Traefik?

No. The proxy reaches your container over the shared Docker network, so publishing host ports is unnecessary and dangerous: it exposes the app directly, bypassing TLS and any proxy-level protections.
