Deploying My First App: Docker, Dokploy, and Two Domains
Part three of the homelab series, and honestly the most chaotic one so far. Last time was giving Proxmox a domain, HTTPS, and locking down the login. This time I wanted to go one step further and actually deploy something. Enter Dokploy, my new favorite self hosted "Vercel but it's mine" setup. Spoiler: pnpm fought me the entire way.
- The Plan
- Installing Dokploy (Port Conflict with Caddy)
- Two Domains, Two Vibes
- Private Dashboard: dokploy.yourdomain.com
- Turns Out I Didn't Need SSH At All
- Getting HTTPS on the Private Dashboard
- Public Site via Cloudflare Tunnel: yourdomain.com
- Deploying the Frontend App
- The pnpm Saga (a Trilogy in Four Parts)
- Swapping an Old Tunnel for a Fresh One
- Wiring the Tunnel to the Deployment
- Why HTTPS Stays Off on the Inside
- Final Checklist
- Closing Thoughts
The Plan
Two domains, two completely different jobs:
- dokploy.yourdomain.com: the deploy dashboard. This one's staying private, Tailscale only, same energy as the Proxmox setup.
- yourdomain.com: the actual app people will visit. Fully public, but via Cloudflare Tunnel instead of poking a hole in my router.
Installing Dokploy (Port Conflict with Caddy)
Ran the install script, got hit with this immediately:
Error: something is already running on port 80
Classic. Caddy was still squatting on 80 and 443 from the Proxmox domain setup, and Dokploy refuses to even start unless 80, 443, and 3000 are all free. Makes sense, it ships its own Traefik instance that needs those ports for itself.
The fix, RIP Caddy:
systemctl stop caddy
systemctl disable caddy
Small trade off: proxmox.yourdomain.com went back to needing :8006 in the URL since Caddy was the one hiding it. Cert's still valid though, no scary warnings, just the port coming back.
Double checked the ports were actually clear, then went again:
ss -tulnp | grep -E ':80 |:443 |:3000 '
curl -sSL https://dokploy.com/install.sh | sh
This installs Docker if it's missing, spins up Docker Swarm, and deploys three containers: the dashboard (port 3000), Postgres, and Traefik (bound to 80/443). Waited a bit, opened http://<tailscale-ip>:3000, made my admin account, and we were off.
Two Domains, Two Vibes
dokploy.yourdomain.com is basically the keys to the kingdom, git tokens, SSH keys, deploy triggers, so it stays locked to the tailnet. yourdomain.com is the opposite, it wants to be seen by literally anyone. Instead of forwarding ports on my home router (hard pass, don't love exposing my home IP), I went with Cloudflare Tunnel, which needs zero inbound ports at all.
Private Dashboard: dokploy.yourdomain.com
Same DNS pattern as everything else so far:
- Type:
A - Name:
dokploy - IPv4: Tailscale IP
- Proxy status: DNS only (gray cloud)
In Dokploy, under Settings → Web Server: Host dokploy.yourdomain.com, HTTPS left off for now, since automatic Let's Encrypt needs port 80 to be publicly reachable for the challenge, which a Tailscale only domain just can't do.
Also patched a known login hang bug while I was in there, where Dokploy's auth layer quietly drops requests if the origin doesn't match what it expects:
docker service update --env-add BETTER_AUTH_TRUSTED_ORIGINS=http://dokploy.yourdomain.com,http://<tailscale-ip>:3000 dokploy
Problem: ERR_CERT_AUTHORITY_INVALID
Opened the domain, got the classic "your connection is not private" scare screen.
Turns out Traefik always binds port 443 no matter what. Since HTTPS was off for this domain, it fell back to its own self signed cert, which no browser will ever trust. On top of that, Chrome was quietly upgrading the bare domain to https:// on its own without me asking.
Fix: type the scheme out loud, http://dokploy.yourdomain.com, or go add an exception under chrome://settings/security → Manage exceptions.
Turns Out I Didn't Need SSH At All
Had a whole mini crisis wondering if I needed to set up SSH over Tailscale just to run commands on the box. Nope. Proxmox already has a browser based terminal baked right into the web UI. Node → Shell, full root access, zero setup. SSH is nice to have for things like easier copy paste or file transfers, but it's not a requirement for basic admin work at all.
Getting HTTPS on the Private Dashboard
Wanted the actual padlock on dokploy.yourdomain.com without opening port 80 to the world. Same DNS-01 trick from the Proxmox setup, reusing the same Cloudflare API token since it already had the right permissions.
apt install -y certbot python3-certbot-dns-cloudflare
mkdir -p /root/.secrets
# /root/.secrets/cloudflare.ini
dns_cloudflare_api_token = YOUR_CLOUDFLARE_TOKEN
chmod 600 /root/.secrets/cloudflare.ini
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d dokploy.yourdomain.com
Uploaded the result straight into Dokploy: Settings → Certificates → Add Certificate, fullchain.pem into Certificate Data, privkey.pem into Private Key. Then Settings → Web Server: HTTPS on, Certificate Provider set to whichever option isn't "Let's Encrypt" (shows up as None or Custom depending on your version), which tells Traefik to skip ACME entirely and just match the uploaded cert by SNI.
Problem: "wait, the fullchain has 4 begin and end blocks, are you sure I paste all of it?"
Totally normal, false alarm. fullchain.pem is the leaf cert plus the intermediate CA cert(s) all mashed into one file, so multiple BEGIN CERTIFICATE blocks is expected, not a bug. Double checked instead of guessing:
grep -c "BEGIN CERTIFICATE" /etc/letsencrypt/live/dokploy.yourdomain.com/fullchain.pem
grep -c "BEGIN PRIVATE KEY" /etc/letsencrypt/live/dokploy.yourdomain.com/privkey.pem
Just make sure certs and the private key go into separate fields, never pasted together as one blob.
Worth remembering for later: this cert expires in 90 days. Certbot renews the file on disk automatically, but Dokploy's copy (pasted into its own database) has no idea that happened. Renewal means manually re pasting the new files.
Public Site via Cloudflare Tunnel: yourdomain.com
Started with the easy dashboard route: create a tunnel in Cloudflare, run cloudflared as a Dokploy app using a token, no CLI needed on the host at all.
- Zero Trust → Networks → Connectors → Create a tunnel → grab the token
- Cloudflare SSL/TLS mode set to Full (definitely not Flexible, that one causes redirect loops with Traefik)
- Dokploy: new Application, Docker Provider, image
cloudflare/cloudflared, envTUNNEL_TOKEN=<token>, Advanced → Arguments:tunnel,run - Published application route on the tunnel: Domain
yourdomain.com, Service URLdokploy-traefik:80
(Plot twist on this one later, keep reading.)
Deploying the Frontend App
Source was GitHub, and the app itself needed an actual running server, not a static export.
- Settings → Git Providers → GitHub → Connect, if not already hooked up
- New Application: pick the repo and branch
- Set the port (3000, standard for most Node frameworks)
- Domains tab: Host
yourdomain.com, Container Port3000, HTTPS off, Certificate None
First attempt used Nixpacks (Dokploy's zero config auto builder). It got close, but I ended up switching to a fully custom Dockerfile for more control, which is where things actually got interesting.
The pnpm Saga (a Trilogy in Four Parts)
Buckle up, this took way longer than it should have.
Problem 1: "packages field missing or empty"
ERROR packages field missing or empty
For help, run: pnpm help install
Had a pnpm-workspace.yaml sitting in the repo without a packages: field. pnpm v10/v11 now requires that field to exist if the file itself exists at all, even for a totally normal, non-monorepo project. This usually sneaks in from running pnpm approve-builds locally, which auto creates the file with just an onlyBuiltDependencies: block and nothing else.
Fix: either delete the file if there's no real monorepo going on, or just add the missing field:
packages:
- "."
Problem 2: the build logs showed npm, not pnpm
Nixpacks decides the package manager purely by which lock file it sees, pnpm-lock.yaml means pnpm, package-lock.json means npm. Nothing to toggle for this in the Dokploy UI.
Fix: commit an actual pnpm-lock.yaml.
rm -f package-lock.json
pnpm install
git add pnpm-lock.yaml package.json
git rm package-lock.json
git commit -m "switch to pnpm lockfile"
git push
This is more or less the point where I got tired of fighting Nixpacks' auto detection and just wrote my own Dockerfile instead. More typing upfront, way less guessing later. Here's the one that ended up actually working, multi stage build for a Next.js app with standalone output:
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# ================================
# Stage 1: Install dependencies
# ================================
FROM base AS deps
WORKDIR /app
# Copy package files first, before copying everything else
COPY package.json pnpm-lock.yaml* .npmrc* ./
RUN pnpm install --frozen-lockfile
# ================================
# Stage 2: Build
# ================================
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable Next.js telemetry during the build
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ================================
# Stage 3: Production runner
# ================================
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non root user, because security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the build output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Switched Dokploy's Build Type to Dockerfile and pointed it at this file. Felt very in control. Was not, in fact, done fighting pnpm.
Problem 3: ERR_PNPM_IGNORED_BUILDS
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @tsparticles/engine@3.9.1, sharp@0.34.5
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
pnpm 10.3+ blocks native or postinstall build scripts by default now, as a supply chain safety measure. pnpm approve-builds is fully interactive though, so it flat out can't run inside a Docker build. Has to happen locally first, and get committed.
First attempt at fixing this used the wrong shape entirely:
# nope, this isn't the format pnpm actually reads
allowBuilds:
'@tsparticles/engine': true
sharp: true
Problem 4: "there are no packages awaiting approval" (narrator: there were, in fact, packages awaiting approval)
Ran pnpm approve-builds @tsparticles/engine sharp locally, pnpm 11 added non interactive positional args for exactly this. It cheerfully said nothing was pending. The Docker build kept failing on the exact same two packages anyway.
Turns out this is a genuine, confirmed pnpm bug, the "awaiting approval" state gets wiped the moment any other install command runs in between, so the CLI reports a clean slate even though the packages are still very much blocked. Several open GitHub issues on this exact thing, other people have hit it deploying to Vercel with the same packages, small pnpm world apparently.
Gave up on the CLI at that point and kept the allowBuilds config as-is in pnpm-workspace.yaml, ran pnpm install locally to make sure the lockfile matched, committed, pushed, and redeployed.
Build still failed. Same two packages, same error, every single time. Turns out the real, actual, final problem had nothing to do with schemas or CLI bugs at all. My custom Dockerfile's COPY line simply never included pnpm-workspace.yaml in the first place. The file was correct, committed, sitting right there in the repo, and Docker just never grabbed it during the build. Pnpm inside the container had no idea any approvals existed, so it kept blocking the exact same scripts every single time.
The fix was embarrassingly small, one word added to a COPY line:
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml .npmrc* ./
That pnpm-workspace.yaml sitting in the middle there is doing all the work. Once it actually made it into the build context, everything just worked. Four separate problems, three different root causes, one missing filename the whole time.
Swapping an Old Tunnel for a Fresh One
While wiring up the tunnel for yourdomain.com, found something unexpected: a tunnel already existed on the account, leftover from an old server that wasn't even in use anymore.
Didn't overthink it, just deleted the old domain route straight from Cloudflare, then created a brand new tunnel via the CLI:
cloudflared tunnel login
cloudflared tunnel create dokploy-tunnel
cloudflared tunnel route dns dokploy-tunnel yourdomain.com
Clean slate, no leftover config to worry about.
One thing worth flagging here: Cloudflare Tunnel actually wants the DNS record to be Proxied (orange cloud), which is the exact opposite of the Tailscale setup used everywhere else in this project, where DNS only (gray cloud) was required instead. Different tool, different rule, easy to mix up if you're moving fast between the two.
Also, "Zero Trust" in the Cloudflare dashboard is just the name of the section where Tunnels happen to live. It's not secretly asking you to set up SSO or an access policy. Creating a tunnel there doesn't drag any of that extra stuff along with it.
Wiring the Tunnel to the Deployment
Ended up going full CLI, straight on the Proxmox host, instead of the token in a container approach:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" | tee /etc/apt/sources.list.d/cloudflared.list
apt-get update && apt-get install cloudflared
Since cloudflared runs directly on the host this way instead of inside a Docker container, it can't reach dokploy-traefik:80 (that hostname only exists inside Dokploy's own internal Docker network). From the host itself, Traefik is just sitting at plain localhost:80, since that port's already published outward.
# ~/.cloudflared/config.yml
tunnel: dokploy-tunnel
credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress:
- hostname: yourdomain.com
service: http://localhost:80
- service: http_status:404
Tested it manually first before trusting it with anything permanent:
cloudflared tunnel run dokploy-tunnel
# worked, so Ctrl+C and make it stick around:
cloudflared service install
systemctl enable --now cloudflared
Why HTTPS Stays Off on the Inside
This one looks backwards at first glance, so worth spelling out clearly. HTTPS being toggled off on the Dokploy domain does not mean the connection is somehow insecure.
Visitor --HTTPS--> Cloudflare edge --tunnel--> cloudflared --HTTP--> Traefik --> App
TLS wraps up at Cloudflare's edge. The padlock a visitor sees in their browser belongs to Cloudflare, not the origin server. Everything past that point rides the tunnel to cloudflared, then travels as plain HTTP to Traefik, since the tunnel itself is already the encrypted leg of the trip.
Flip HTTPS on here instead, and Traefik adds an automatic HTTP to HTTPS redirect on its router. Since the tunnel always hands it HTTP no matter what, that redirect fires every single time, Cloudflare dutifully follows it, and the request loops right back through the tunnel as HTTP again. Forever. Same underlying cause as the Flexible SSL warning from earlier in the series, just triggered from the Traefik side this time instead of Cloudflare's.
Final Checklist
-
docker service lsshows dokploy, postgres, and Traefik all running -
http://dokploy.yourdomain.comloads the dashboard from a Tailscale connected device -
https://dokploy.yourdomain.comloads with a valid cert, no scary browser warning -
systemctl status cloudflaredshows active and connected -
https://yourdomain.comloads the actual deployed app - A fresh
git pushtriggers an automatic redeploy, if auto deploy's turned on - The Dockerfile's COPY line actually includes
pnpm-workspace.yaml, not just the repo
Closing Thoughts
The infrastructure half of this (Docker, Dokploy, Traefik, tunnels) went roughly how I expected, mostly just careful reading and configuration. The real time sink was pnpm's build script approval system stacking three separate quirks on top of each other, and the actual fix being one filename missing from a COPY line the entire time. Feels about right for how these things usually go.
Next up: probably a second app behind the same tunnel, just to see how painlessly the routing handles multiple projects without touching Cloudflare's dashboard again.