featured image

Mastering Dokploy: Building a Sovereign, Automated Deployment Engine on a VPS

This tutorial provides a comprehensive, technical deep-dive into using Dokploy to transform a bare-metal VPS into a powerful, automated deployment platform. It covers the architectural decisions, security considerations, and orchestration strategies required to host production-grade applications with zero-trust networking and seamless CI/CD pipelines.

Published

Fri Dec 19 2025

Technologies Used

Dokploy Docker Tailscale
Intermediate 29 minutes

Managed platforms are a trap. Vercel and Heroku will give you a slick push-to-deploy experience for a few months, and then you’ll hit a traffic spike or a storage limit and discover the pricing cliff. Meanwhile, a $6/month VPS is sitting there fully capable of doing everything you need — it just requires actual configuration.

This tutorial walks through replacing that dependency with Dokploy: a self-hosted control plane that gives you GitHub webhook deployments, automatic SSL provisioning, and zero-downtime rolling updates. We’ll spin up a Next.js frontend backed by PostgreSQL, wire it through Traefik for routing, and keep the database off the public internet where it belongs.

What You Need Before Starting

  • An Ubuntu 24.04 LTS VPS with a public IP
  • Solid enough Docker fundamentals to understand what a Swarm service is vs. a Compose container
  • A registered domain with DNS access (you’ll need an A record pointing at the VPS)
  • A GitHub repo with a deployable app — Next.js, Go, whatever

You don’t need to know Traefik internals ahead of time. We’ll cover the parts that matter as we go.

How Dokploy Actually Works: The Harbor Master Model

Dokploy isn’t just a pretty UI on top of docker run. It’s a control plane that manages Docker Swarm and Traefik together. When a GitHub webhook fires on a push to main, Dokploy detects it, builds a Docker image with Nixpacks (the same builder Railway uses — zero Dockerfile required for most apps), and hands the routing configuration to Traefik via container labels.

flowchart TD
    subgraph Git Provider
        A[GitHub Repository]
    end

    subgraph The Sovereign VPS
        B[Dokploy Control Plane]
        C{Traefik Reverse Proxy}
        D[Next.js Web Container \n :3000]
        E[(PostgreSQL Container \n :5432)]
        
        B -- 1. Triggers Build --> D
        B -- 2. Configures Labels --> C
        C -- 3. Routes Traffic --> D
        D -- 4. Internal Network --> E
    end

    subgraph Public Internet
        F[User / Web Browser]
    end

    A -- Webhook (Push to Main) --> B
    F -- HTTPS (Port 443) --> C

Traefik is the piece that makes this work cleanly. Unlike Nginx, it listens to the Docker daemon socket directly. When Dokploy launches a container with the label traefik.http.routers.webapp.rule=Host('myapp.com'), Traefik picks up that event and creates the routing rule in memory — no config file editing, no service restart, no downtime.

Installing Dokploy and Fixing Port Bindings

Installation is a single command. Inspect it first if you’re on a production machine:

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

This configures Docker, initializes a Swarm, and starts the Dokploy control plane UI on port 3000. Once it’s up, navigate to http://your-vps-ip:3000 to create your admin account.

In certain networking environments — particularly when recovering from port collisions or reconfiguring an existing setup — you may need to manually rebind how Dokploy’s Swarm service exposes that port:

docker service update --publish-rm "published=3000,target=3000,mode=host" dokploy

# Verify the endpoint spec was applied
docker service inspect dokploy --format '{{.Spec.EndpointSpec}}'

We’re using docker service update here rather than docker run because Dokploy runs as a Swarm service, not a plain container. The Swarm mode declarative model is what gives you automatic container restart on crash — the manager continuously reconciles the desired state (1 running replica) against the actual state.

The Database: Don’t Expose Port 5432 to the World

This is the most common mistake I see in self-hosted setups. When developers want to connect to their database from a GUI tool like DBeaver, they map the port:

services:
  postgres:
    image: postgres:15
    ports:
      - "5432:5432"   # Don't do this
    environment:
      - POSTGRES_PASSWORD=supersecret

Docker bypasses UFW entirely when it punches port mappings into iptables. That 5432:5432 line is exposing your database directly to the public internet, regardless of what your firewall rules say. You’ll have brute-force bots knocking within minutes.

The right approach keeps the database internal. No ports directive at all:

services:
  database:
    image: postgres:16-alpine
    restart: always
    # No 'ports' block — only accessible on the internal Docker network
    environment:
      POSTGRES_USER: admin
      POSTGRES_DB: production_db
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  webapp:
    image: ghcr.io/my-org/my-nextjs-app:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.webapp.rule=Host(`myapp.com`)"
      - "traefik.http.routers.webapp.entrypoints=websecure"
      - "traefik.http.services.webapp.loadbalancer.server.port=3000"

The webapp reaches the database over Docker’s internal bridge network using the service name as hostname (database:5432). Traefik handles external HTTPS routing through the labels. Nothing that doesn’t need to be public is public.

Use Dokploy’s Secrets UI rather than hardcoding passwords in Compose files. The POSTGRES_PASSWORD_FILE approach mounts the secret directly into container memory — it never touches disk or your Git history.

Pitfalls You’ll Hit in Production

Let’s Encrypt rate limits. Dokploy provisions SSL automatically through Let’s Encrypt, which has strict limits: 5 duplicate certificate requests per week per domain. If your app has a bug that causes containers to keep cycling during deployment, you can exhaust this limit and be locked out of SSL provisioning for days. Always test locally with a staging domain before pointing your production URL at a new deployment.

Zero-downtime rollouts. By default, Dokploy’s update strategy stops the old container before starting the new one — that’s a 5-15 second outage window. To avoid this, configure a rolling update strategy in your Swarm service definition. If you’re managing containers externally, Watchtower with --rolling-restart will spin up the new version, wait for health checks, and only then terminate the old one.

Secret management. Never put secrets in the Compose YAML that gets committed to a repo. Use Dokploy’s Environment Variables tab for runtime injection, or Docker secrets mounted at /run/secrets/ for anything sensitive enough to warrant keeping out of environment variables entirely.

Nixpacks detection. The builder analyzes your repo for language markers (package.json for Node, go.mod for Go, etc.) and generates an optimized layered image. For most standard projects this works immediately. If you have a non-standard repo layout, you can drop a nixpacks.toml in the root to override detection.

With this setup you’ve got a deployment pipeline that reacts to a git push, builds a fresh image, provisions SSL, and routes traffic — all without touching a config file manually. The infrastructure cost is your VPS. The operational cost is understanding what’s actually running, which is what this tutorial gave you.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!