# How to Deploy a .NET App to a VPS With Dokploy

> Deploy a .NET API and Postgres to your own VPS with Dokploy: keep the panel private, define the app in Compose, add a domain with HTTPS, and check readiness.

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

Canonical: https://milanjovanovic.tech/blog/deploy-dotnet-app-to-vps-with-dokploy

To deploy a .NET app to a VPS with Dokploy, install Dokploy on a Linux server, keep its panel private, define the API and Postgres in a Compose file, and add a domain on the service's Domains tab.
Dokploy then builds the app, configures Traefik routing, and requests a Let's Encrypt certificate.

I use [**Dokploy**](https://dokploy.com) for the servers behind [**Katabench**](https://katabench.com).
For a small application, start with one server, a private panel, and a Compose file checked into Git.
You still own server updates, capacity, backups, and recovery, and you should accept that tradeoff before you run the install command.

![Public HTTPS reaches Traefik and the ASP.NET Core API. Postgres stays on the private Compose network with a persistent volume. The admin reaches Dokploy through an SSH tunnel.](https://milanjovanovic.tech/blogs/articles/deploy-dotnet-app-to-vps-with-dokploy/dokploy_network_topology.png)

The [**complete sample**](https://milanjovanovic.tech/code/dokploy-dotnet-postgres.zip) contains a .NET 10 API, Postgres 18, a Dockerfile, and a verification script you can run locally before renting anything.

## Step 1: Create the Server and Its Firewall

[**Dokploy's requirements**](https://docs.dokploy.com/docs/core/installation) are at least 2 GB of RAM and 30 GB of disk on a supported Linux distribution such as Ubuntu 24.04.
A 2-core, 4 GB server at [**Hetzner**](https://www.hetzner.com/cloud) costs around $6 per month, and that's a reasonable size for the panel, an API, and a database.
Check current prices and measure your workload before assuming how many applications it can carry.

Create the firewall at the provider before you create the server.
Allow public ports `80` and `443`, restrict SSH to your IP address, and leave everything else closed.
This has to be the provider's firewall, because [**Docker can route published ports around UFW**](https://milanjovanovic.tech/blog/docker-bypasses-ufw).

Use SSH keys, add a sudo-capable `deploy` user, and disable password login after testing the new login in a second terminal.
The [**VPS deployment checklist**](https://milanjovanovic.tech/blog/vps-deployment-checklist) shows how to confirm what SSH actually applies.

## Step 2: Install Dokploy and Keep the Panel Private

Run the documented installer from a root shell:

```bash
curl -sSL https://dokploy.com/install.sh | sh
```

It needs ports `80`, `443`, and `3000` free on the server, and it installs Docker if it's missing.
Keep `3000` blocked at the provider firewall and reach the panel through a tunnel:

```bash
ssh -N -L 3000:localhost:3000 deploy@your-server-ip
```

Open `http://localhost:3000`, create the administrator, and enable two-factor authentication.
A [**Tailscale network**](https://milanjovanovic.tech/blog/build-your-own-vpn-with-tailscale) is another way to keep management private, and Katabench runs the same idea on **Headscale**.

The panel controls your secrets and can run commands on the host, so treat access to it like access to the server.
Dokploy published dozens of critical advisories in 2026, including a [**pre-auth admin takeover**](https://milanjovanovic.tech/blog/dokploy-vs-coolify) in some older versions.
Install the current release, and schedule updates like you do for the operating system.

## Step 3: Define the App in Compose

The sample's API has a tiny notes table and separate liveness and readiness endpoints.
The readiness check is the part worth copying into `Program.cs`:

```csharp
builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(
    builder.Configuration.GetConnectionString("Database")!));

builder.Services
    .AddHealthChecks()
    .AddCheck<DatabaseCheck>("database", tags: ["ready"]);

var app = builder.Build();

// Liveness runs no checks: it only proves the process can respond.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false
});

// Readiness runs the database check.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});
```

`DatabaseCheck` runs `SELECT 1` and returns `Unhealthy` if that fails.

The Dockerfile is a normal multi-stage build.
It also installs `curl`, because the Compose healthcheck uses it:

```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY DemoApi.csproj .
RUN dotnet restore
COPY Program.cs .
RUN dotnet publish -c Release --no-restore -o /out

FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out .
USER $APP_UID
ENTRYPOINT ["dotnet", "DemoApi.dll"]
```

Put `compose.yml` next to it.
This file publishes no application or database ports:

```yaml
services:
  api:
    build: .
    restart: unless-stopped
    environment:
      ConnectionStrings__Database: Host=db;Username=postgres;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD};Database=postgres;Timeout=2;Command Timeout=2
    expose: ['8080']
    healthcheck:
      test: ['CMD', 'curl', '-fsS', 'http://localhost:8080/health/live']
      interval: 10s
      timeout: 3s
      retries: 3
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 2s
      timeout: 2s
      retries: 20
volumes:
  pgdata:
```

`Host=db` uses the Compose service name, not the server's public IP.
`depends_on` waits for the database healthcheck during startup, and it doesn't stop the API if Postgres goes down later.

The [**Postgres 18 image**](https://hub.docker.com/_/postgres) stores version-specific data under `/var/lib/postgresql`.
Don't copy the old `/var/lib/postgresql/data` mount from a Postgres 17 Compose file.

Before you touch the server, run the whole thing locally:

```bash
node verify.mjs
```

The script builds the images, stops and restarts the database, recreates the stack, and removes everything it created when it finishes.

## Step 4: Add the Domain and Deploy

Create a project and a **Compose** service pointing at your repository, then set its Compose path.
Add `POSTGRES_PASSWORD` in the panel's environment settings, using a generated value:

```bash
openssl rand -hex 32
```

Point a DNS `A` record at the server.
On the service's [**Domains**](https://docs.dokploy.com/docs/core/docker-compose/domains) tab, select `api`, port `8080`, your hostname, HTTPS, and Let's Encrypt.

Dokploy adds the routing labels and the `dokploy-network` connection during deployment, so changing a domain requires a redeploy.
**Preview Compose** shows you the file it will actually run, so check it first if routing misbehaves.

Now deploy, and request the readiness endpoint through the public domain:

```bash
curl -i https://app.example.com/health/ready
```

Look for `200` and `Healthy`.
That one request checks the certificate, the proxy route, the API, and the database connection together.

If you get a `503` with `Unhealthy`, read the database connection error in Dokploy's logs.
The other failures come from Traefik, and each code points somewhere different: a `404` means no router matched or the container is unhealthy, a `502` means the wrong port, and a `504` means no shared network.
[**This table**](https://milanjovanovic.tech/blog/operate-dotnet-apps-on-dokploy) has what to check for each one.

## Step 5: Check More Than the Process

Here's the output from `verify.mjs`, which uses a local Compose override and the same application:

```text
database stopped
GET /health/live -> 200
GET /health/ready -> 503
database restarted
GET /health/ready -> 200
container recreation: saved row survived
```

While the database was stopped, Docker still reported the API container as `healthy` with zero restarts.
That's because the Compose healthcheck calls the liveness endpoint, and a restart wouldn't have helped anyway.

Keep it that way.
Traefik removes unhealthy containers from routing, so a healthcheck on readiness would turn this `503` into a `404` during a database outage.

Point your uptime monitor at `/health/ready` instead, from outside the server.

## Why Use Dokploy Instead of Plain Docker Compose?

The Compose file above works without Dokploy.
You would then write the Traefik configuration, certificate resolver, and deployment scripting yourself.

Dokploy gives you the Azure App Service experience on your own machine: push code, get a deployment, and routing is handled for you.
The cost is a privileged control plane on your server that you have to keep updated and private.
If you're comparing options, [**Dokploy vs Coolify**](https://milanjovanovic.tech/blog/dokploy-vs-coolify) goes through the differences.

For repeatable releases, have CI build the image and tag it with the commit SHA, then have Dokploy deploy that exact image through its [**webhook or API**](https://docs.dokploy.com/docs/core/auto-deploy).
That gives you a previous image to return to, which a rebuild from Git doesn't.

## What Does This Setup Not Cover?

A persistent volume survives a container replacement, but it's still on this server.
Configure [**off-site database backups**](https://milanjovanovic.tech/blog/how-i-migrated-my-website-to-cloudflare-and-saved-228-dollars), then practice [**restoring a stored dump**](https://milanjovanovic.tech/blog/test-postgres-backup-restore) before you put useful data here.
Back up Dokploy's own configuration as well, because the [**web server backup**](https://docs.dokploy.com/docs/core/backups) is separate from your database backups.

One server means one hardware fault can take the application down.
Write down how you would recover, and choose this setup only if that recovery time fits the application.

The sample creates its schema on startup and connects with the database's admin role to stay small.
Before adapting it for real data, use explicit migrations and a role with only the permissions the API needs.
[**Operating .NET apps on Dokploy**](https://milanjovanovic.tech/blog/operate-dotnet-apps-on-dokploy) covers the next set of problems, including forwarded headers, resource limits, and recovery.

The local script doesn't provision a VPS or issue a public certificate.
Those steps follow Dokploy's documentation, so confirm them with the `curl` check above on your own server.

## Summary

Keep the panel private, define your API and database in a Compose file that publishes no ports, and let Dokploy handle routing and certificates through the Domains tab.
Then check `/health/ready` through the public domain, because that's the one request that proves the whole path works.

Run the [**sample**](https://milanjovanovic.tech/code/dokploy-dotnet-postgres.zip) locally first, and go through the [**deployment checklist**](https://milanjovanovic.tech/blog/vps-deployment-checklist) before you rely on the server.
My [**self-hosting course**](https://milanjovanovic.tech/pragmatic-self-hosting) builds on this setup with private administration over Tailscale, automated releases, and a restore exercise.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### How do I deploy a .NET app to a VPS with Dokploy?

Install Dokploy on a Linux server, keep its panel private, and put your API and database in a Compose file in your repository. Create a Compose service in Dokploy, set your secrets, add a domain with HTTPS on the Domains tab, and deploy.

### What does Dokploy manage on a VPS?

Dokploy deploys applications from Git or container images and configures routing and HTTPS through Traefik. You still operate the server, its capacity, updates, backups, and recovery.

### How much memory does Dokploy need?

The Dokploy documentation asks for at least 2 GB of RAM and 30 GB of disk. A 2-core, 4 GB server is a comfortable starting point for the panel, a small API, and a database, but measure your own workload before adding more applications.

### Should the Dokploy panel be publicly accessible?

Keep port 3000 private and reach the panel through an SSH tunnel or a VPN. The panel controls deployments and application secrets, so treat access to it like access to the server, enable two-factor authentication, and keep Dokploy updated.

### Does a Docker healthcheck restart an unhealthy container?

Not on plain Docker or Compose. A healthcheck reports container health, and a restart policy acts when the process exits. A dependency failure can leave a running container unable to serve useful requests, so monitor application readiness separately.

### Can I deploy a .NET app to a VPS without Dokploy?

Yes. You can run the same Compose file with a reverse proxy you configure yourself. Dokploy saves you from writing the proxy configuration, certificate setup, and deployment scripting by hand, at the cost of running a privileged panel on your server.
