A walkthrough for anyone looking to set up their first VPS. Cheap box, locked-down access, apps deployed from a git repo. A safe environment to experiment and explore in, built with the same habits that carry over when you later run real production deployments.
This is the setup I actually run, not a textbook version of it. Hetzner for the box, Tailscale so I can hide SSH from the public internet, Docker for everything that runs on it, and Dokploy on top so I’m not hand-writing compose files at 1 AM. None of this is novel. It’s just nice to have one page that ties it together.
What you’re actually picking up by setting this up is Linux server experience, which is the layer underneath every modern deployment pipeline. There’s a useful spectrum to keep in mind. Infrastructure (a raw VPS like this) hands you a Linux box and gets out of your way. Platform-as-a-service like Vercel, Fly.io, or Render abstracts the box away and lets you push code straight from a repo. SaaS like Linear or Notion abstracts even further, packaging the whole thing as someone else’s product. Each layer is faster than the one below it, and each layer hides more of what’s happening underneath. Both PaaS and SaaS are great when you need them and know what they’re doing on your behalf. But neither teaches you what’s happening at the process, network, or filesystem level. A VPS does. Worth seeing the bottom layer at least once before letting a platform manage it for you.
Disclaimer. This is what works for me on my own machines. I’m a student writing down what I learned, not a professional sysadmin. If you follow this guide and something breaks, leaks, or costs you money, that’s on you, not on me. Read what you’re typing before you run it, keep your own backups, and if you’re hosting anything that holds real user data or money, get a second opinion from someone who does this for a living.
Why Hetzner
Cheap, predictable, EU-based. A CX22 instance (2 vCPU, 4 GB RAM, 40 GB NVMe) is about €3.79/month. Their ARM tier (CAX11) is even cheaper and totally fine for small web apps if your stack runs on ARM. For comparison, DigitalOcean’s closest tier is roughly twice the price for the same specs.
If you’d rather not babysit memory from day one, look at the CX32: 4 vCPU and 8 GB RAM for about €6.49/month (or CAX21 on ARM, similar specs, a bit cheaper). Same family, twice the headroom. That’s the version I’d pick if I knew up front I’d be running more than a handful of containers, or stacking a couple of Postgres instances next to my apps.
Sign up here: hetzner.com/cloud.
Picking a location
Hetzner has data centers in Germany (Falkenstein, Nuremberg), Finland (Helsinki), the US (Ashburn VA, Hillsboro OR), and Singapore (since 2024). For someone in the Philippines, Singapore is the obvious choice for backend latency: same APAC region, roughly 50 ms round-trip from Manila.
That said, you don’t actually need a nearby region for most things. If you put Cloudflare in front of your VPS (the bonus section below), Cloudflare caches static assets at the edge close to your visitors and the only thing crossing the ocean is dynamic API calls and database queries, a few hundred bytes per request. Even at ~300 ms round-trip to an EU box, that’s fine for portfolios, n8n workflows, dashboards, web apps, automations, and the personal-tool stack in general.
Where region choice actually matters is egress/ingress heavy workloads: video streaming, large media downloads, anything pumping gigabytes per session. Those scale linearly with latency because every megabyte still has to round-trip ACKs across the ocean. If you specifically want to self-host a Jellyfin or Plex server, pick Singapore (or run it at home), not an EU box.
For a Philippines-based reader, my default recommendation is Singapore. It’s the same price as the EU regions and gives you APAC latency for free. EU is fine as a fallback if Singapore is sold out or if you want geographic separation from your other personal infrastructure.
What a CX22 can and can’t do
Be realistic about what 2 vCPU and 4 GB of RAM gets you. This box is a workhorse for small services, not a server farm.
Comfortable on a single CX22:
- A handful of web apps behind a reverse proxy (portfolio, side projects, a Discord bot).
- Workflow tools like n8n for automations.
- Personal services: a recipe manager (Mealie), invoicing (Invoice Ninja), budgeting (Actual Budget), an audiobook server, a notes app. Each is small. Stacking 6 to 8 of them is fine if you watch memory.
- A separate Postgres (or MariaDB) container per service, which is honestly how I run things. Each app gets its own DB container alongside it in the compose file. Memory usage stays reasonable as long as the apps themselves are small, and you get clean isolation between services. Eight or so services with their own Postgres is fine on 4 GB.
- A static site generator’s build step now and then.
Don’t try to do this on a CX22:
- LLM inference of any real model. Even a 7B-parameter model needs 8+ GB of RAM just to load, and without a GPU you’re looking at seconds per token. Don’t run Ollama or vLLM on a CPU-only VPS unless you genuinely just want a demo to fail in front of you. For LLM use cases, use a managed API (Anthropic, OpenAI, Groq) or rent GPU time on RunPod or vast.ai. It will be cheaper than the cloud GPU options.
- Heavy video transcoding. Jellyfin or Plex with multiple concurrent transcodes will pin the CPU. Direct-play works fine, transcoding does not.
- Game servers beyond the smallest Minecraft setup (and even that, only for a few players).
- High-traffic production sites. As a rough line in the sand, once you’re sustaining more than about 100 concurrent users or roughly 50 requests per second on a dynamic app, this box will start to feel it. That’s the point to start thinking about a bigger tier or splitting services across machines. For a portfolio or side project, you’ll never see those numbers. This is a learning and side-project tier, not a small-business production tier.
If you outgrow CX22, the upgrade path is a single dropdown in the Hetzner console and a reboot. You’re not locked in.
Before anything: get your secrets in order
Two pieces of plumbing you want sorted before you create any account. Both take 10 minutes and they save you a lot of pain later.
Password manager. Use Bitwarden. Free tier is enough. Generate a long random master password, write it on paper, put the paper somewhere safe. From now on, every account you make (Hetzner, Cloudflare, GitHub, whatever) gets a 30+ character random password stored in Bitwarden. You will never type or remember these passwords again. The point is that if any one service gets breached, the password leaked there is useless anywhere else.
A separate authenticator app. Use Google Authenticator, Aegis (Android, open source), or Raivo (iOS). The important word is separate. Do not store TOTP codes inside Bitwarden. The whole reason 2FA exists is that the two factors live in different places.
For SSH keys specifically: Bitwarden does have an SSH key feature now, and it works. But for a single VPS as a beginner, just keep your SSH key in ~/.ssh/ on your laptop. That’s the standard. If you start managing multiple machines and want to sync keys across devices, then look at the Bitwarden SSH integration or 1Password’s SSH agent.
Step 1: Make the server
In the Hetzner Cloud console:
- Create a new project. Call it whatever.
- Add an SSH key. If you don’t have one, generate one locally:
Paste the contents ofssh-keygen -t ed25519 -C "[email protected]"~/.ssh/id_ed25519.pubinto Hetzner. - Create a server. Ubuntu 24.04, CX22 (or CAX11 ARM). Pick a location. Attach the SSH key you just added. When the form asks how to authenticate the root account, pick SSH key only and skip setting a root password. That’s the first layer of “no password login”: no password ever exists for the root account, so there’s nothing to brute-force.
- Optional but recommended: turn on the cloud firewall and only allow SSH (port 22 for now), HTTP (80), HTTPS (443). You’ll tighten this later.
Give it ~30 seconds to boot, then grab the public IP from the dashboard.
Step 2: First SSH and a non-root user
ssh root@<your-server-ip>
If terminals are new to you
Three small things to know before you start typing.
Pasting from your clipboard. Inside a terminal, the paste shortcut is not Ctrl+V (that does something else ). Use Ctrl+Shift+V on Linux and Windows terminals, or Cmd+V on macOS. Right-click inside the terminal usually pastes too. Worth knowing because most of the commands below are easier to copy and paste than to retype.
Editing files with nano. Whenever this guide says “edit /etc/something/file”, the actual command is sudo nano /etc/something/file. Nano is the friendly Ubuntu default. Inside nano: type to add text, use arrow keys to move around, Ctrl+O then Enter to save, Ctrl+X to quit. If it asks “Save modified buffer?”, press Y.
Don’t panic if vim opens by accident. Some commands (like visudo or git commit) launch vim instead of nano. Vim is famously hard to exit blind. To bail out without saving: press Esc, then type :q! and press Enter. To save and exit: :wq. You can change your system default editor later if you want, but for now, just knowing how to quit vim is enough.
Back to the work.
Update the system first
Hetzner ships current Ubuntu images, but “current” was current the day they built the snapshot, not today. Refresh the package list and apply any pending security updates before you do anything else:
apt update && apt upgrade -y
apt update pulls metadata for what’s available. apt upgrade -y installs newer versions of anything already on the box, with the -y skipping the “are you sure?” prompts. If a kernel update lands you’ll see a note telling you to reboot. Run reboot, wait 30 seconds, then ssh root@<ip> again to come back in.
You’re root. Don’t stay root. Make a user:
adduser bernard
usermod -aG sudo bernard
mkdir -p /home/bernard/.ssh
cp /root/.ssh/authorized_keys /home/bernard/.ssh/
chown -R bernard:bernard /home/bernard/.ssh
chmod 700 /home/bernard/.ssh
chmod 600 /home/bernard/.ssh/authorized_keys
Why bother? Log out, log back in as your new user, confirm sudo works.
Give the box some swap
Hetzner Cloud images ship without a swap file. That’s fine on day one, but the moment you’re running 6–8 services with their own Postgres containers on a 4 GB CX22, a memory spike during a backup or a heavy query can trip the Linux OOM killer, which will silently terminate one of your containers. “Why did my database just die?” is not a great evening. Swap doesn’t make the box faster. It just gives the kernel somewhere to dump cold pages so it doesn’t have to kill a live process to free up RAM.
Add a 2 GB swap file (bump to 4 GB if you plan to run heavier workloads):
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Confirm with free -h. You should see ~2.0 Gi listed under Swap. The DigitalOcean tutorial How To Add Swap Space on Ubuntu is the canonical reference and works as-is on Ubuntu 24.04; it also covers tuning swappiness and vfs_cache_pressure if you want to go deeper.
Step 3: Lock SSH down (and kill password login)
Edit /etc/ssh/sshd_config (or drop a file in /etc/ssh/sshd_config.d/):
Port 2200
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
That PasswordAuthentication no line is the durable enforcement of what you already set up in Step 1. You picked SSH key only at server creation, so the root account has no password to begin with. This config edit makes the rule explicit at the sshd level, which matters because somebody (you, future you, a forgotten command) can later set a password with passwd bernard and accidentally re-open password login. Setting it in sshd_config is the version that survives. The moment your VPS has a public IP, bots start trying root/admin/password combinations against port 22. Thousands per day. With PasswordAuthentication no, every one of those attempts is rejected before the password is even evaluated.
Moving SSH off port 22 won’t stop a determined attacker. It will drop the bot-noise in your logs by 99%, which is genuinely useful. Reload:
sudo systemctl reload ssh
Before testing the new port, update the Hetzner cloud firewall: add a rule allowing TCP 2200 from your IP (or 0.0.0.0/0 if you’re under CGNAT ). Leave the existing port 22 rule in place for now. You’ll remove it only after the new port is confirmed working. If you skip this step, the cloud firewall will block your test connection before it ever reaches the VPS, you’ll see a timeout, and you’ll think you broke SSH when you actually didn’t.
Now don’t close your current session yet. Open a new terminal and confirm ssh -p 2200 bernard@<ip> works. Then close the old one. If you lock yourself out, you’ll need Hetzner’s web console to get back in. Annoying but recoverable.
Once 2200 works, go back to the Hetzner cloud firewall and remove the port 22 rule. The hardened port is now the only public SSH path.
A short word on “hardening”
You’ll see the term “hardening” everywhere in server tutorials. It sounds vague because the concept is vague: it just means making a system harder to attack by removing or restricting things you don’t need. Concretely, for a VPS, hardening is the four things you just did or are about to do:
- No root SSH, no password SSH, only keys.
- SSH on a non-default port to cut log noise.
- A host firewall (UFW) that drops everything you didn’t explicitly allow.
- fail2ban to ban IPs that keep failing to log in.
Each one alone is mediocre. Stacked, they’re enough that your VPS is no longer the easy target on the block. Attackers are lazy. They move on.
Step 4: UFW on the host
Two firewalls is not paranoid. The cloud firewall sits at Hetzner’s network edge. UFW runs on the box itself. If one is misconfigured, the other still has your back.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2200/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
One more rule to add now, before you forget: allow all traffic on the Tailscale interface. Without this, UFW’s default-deny will block anything you try to reach over your tailnet (like Dokploy’s admin panel on port 3000 in Step 8). Tailscale traffic is already authenticated and encrypted, so allowing the whole interface inbound is fine:
sudo ufw allow in on tailscale0
If you haven’t installed Tailscale yet, run this immediately after Step 5 instead.
Add fail2ban while you’re here. It bans IPs after repeated failed SSH attempts:
sudo apt update && sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
The defaults are almost fine. One catch: the default sshd jail watches port 22, not your new 2200. Without an override, fail2ban is monitoring an empty port while the actual login attempts go unchecked. Fix it by creating /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = 2200
Then reload:
sudo systemctl restart fail2ban
Now confirm it’s watching the right port: sudo fail2ban-client status sshd should show the jail active.
Step 5: Tailscale (this is the good part)
Tailscale gives every machine you own a private IP on a mesh network. Once it’s installed on your VPS and your laptop, you can SSH to the VPS over Tailscale instead of the public internet. Then you can close port 2200 on the public firewall entirely.
How it actually works (the 60-second version)
Tailscale is a thin wrapper around WireGuard, a modern VPN protocol. The clever part isn’t the encryption (that’s just WireGuard doing WireGuard things). The clever part is how it gets two of your machines, sitting behind random NATs on the public internet, to talk directly without you setting up port forwarding.
Step through the lifecycle of a Tailscale connection below.
The practical result: your laptop and your VPS get IPs on a private 100.x.x.x range that only exist inside your tailnet. You SSH to those IPs (or to a hostname Tailscale invents for you) instead of the public IP. The public internet has no idea SSH is running.
Installing it
On the VPS:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh
If you’d rather verify the install script first or use the official apt repository (recommended for production setups, since it gets you signed package updates through apt upgrade), the canonical install docs are at tailscale.com/download/linux/ubuntu. The one-liner above is fine for a personal box, but the apt approach is the same handful of commands and gives you a cleaner upgrade path later.
It’ll print a URL. Open it on your laptop (which also needs Tailscale running) and authenticate. Now your VPS shows up under its hostname in your tailnet.
From your laptop:
ssh bernard@<vps-hostname>
That command works because of the --ssh flag you passed earlier. Tailscale SSH intercepts port 22 on the tailnet IP and proxies the connection to the local sshd, using your tailnet identity for auth. Without --ssh, the command would fail: regular sshd on the VPS is now on port 2200, so you’d need ssh -p 2200 bernard@<vps-tailnet-ip> instead. Tailscale SSH is the simpler version, but it’s a real choice you’re making. Read tailscale.com/docs/features/tailscale-ssh if you want to understand exactly what it changes about your auth model.
Once this works, you can remove the SSH rule from your Hetzner cloud firewall. Only 80 and 443 stay open to the public.
One caveat before you remove the rule: Step 8 below walks you through Dokploy’s initial admin setup, and Option B in that step uses a public-internet SSH tunnel as a fallback for the first-time admin page. If you close port 2200 in the cloud firewall now, you lose that fallback and Tailscale becomes the only path in. That’s usually fine, but if your Tailscale ever drops (auth expires, key gets revoked, you reinstall your laptop), you’ll be locked out until you either restore Tailscale via the Hetzner web console or reopen 2200. Reasonable rules of thumb: keep 2200 open during the Dokploy initial setup, close it after the admin panel is reachable via its own domain, and keep the Hetzner web-console password handy as a permanent backdoor.
Tailscale’s free Personal plan covers unlimited personal devices and up to 6 users on one tailnet, which is plenty for a few machines and a couple of friends. Pricing: tailscale.com/pricing. Docs: tailscale.com/kb.
Step 6: Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker bernard
Log out and back in for the group change to take effect. Test:
docker run hello-world
Why Docker, actually
If you’ve never used containers, the short version: a Docker container is a packaged-up filesystem and process that runs in isolation from the rest of your system. The app inside the container thinks it owns the machine. It can’t see your other apps, can’t conflict with their dependencies, can’t accidentally write over their files.
What this gets you in practice:
- Stop fighting your system. Run a Node 18 app and a Node 22 app on the same VPS without nvm gymnastics. Run a Postgres 14 and a Postgres 16 side by side. Each container brings its own version of everything.
- Same thing in dev and prod. The container that runs on your laptop is bit-for-bit the same container that runs on the VPS. “It worked on my machine” still happens, but a lot less.
- Throwing things away is cheap.
docker rmand the app, plus all its leftover state, is gone. No half-uninstalled packages, no dangling systemd units, no/etc/files you forgot about. - Standard ecosystem. Nearly every open-source tool you’d want to self-host ships a Docker image. You don’t read install instructions anymore. You read a
docker-compose.yml.
The cost is a bit of upfront learning. Containers, images, volumes, networks, compose files. Maybe a weekend to feel comfortable. After that you stop thinking about installation as a concept and start thinking about “which image, which port, which volume.”
Step 7: Writing compose files (and using AI to draft them)
This is the part of the workflow I’ve come to enjoy most. Every service I self-host lives as a single docker-compose.yml (or dokploy-compose.yml) in a git repo. The repo is the deployment spec. If the VPS evaporates tomorrow, I clone the repo to a new box and everything comes back up.
Define routing inside the compose file, not in the UI
Dokploy lets you configure domains in its web UI. Don’t. Define them as Traefik labels inside the compose file instead. Here’s a snippet from my actual Mealie setup:
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v2.7.1 # pin a real version, never :latest
# ... env, volumes, etc.
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.mealie.rule=Host(`mealie.example.com`)"
- "traefik.http.routers.mealie.entrypoints=websecure"
- "traefik.http.routers.mealie.tls.certresolver=letsencrypt"
- "traefik.http.services.mealie.loadbalancer.server.port=9000"
The reason: configuration that lives in a UI has no history. You change the domain, the previous value is gone, and six months later you can’t remember what it was or why you changed it. Labels in a compose file are diffed by git. Every change has a date, a commit message, and a reason. That’s the version history nobody talks about until they need it.
Use AI to draft, then read line by line
I draft most new compose files with Claude or Gemini, then read every line before running it. Drop the block below into your model of choice and fill in the brackets:
Write a docker-compose.yml for [SERVICE NAME] that I'll deploy on Dokploy.
Requirements:
- Use the official image [ghcr.io/.../latest], pinned to a specific tag (no :latest).
- Add a [Postgres 17 / MariaDB / Redis] container as a sibling service if the
app needs one. Use a healthcheck so dependent services use
depends_on with `condition: service_healthy`.
- Do NOT publish ports to the host. Use `expose:` only. Only the Traefik
router should make this service reachable from the outside.
- Routing: join the external `dokploy-network` and use Traefik labels for
routing to host `[service.example.com]`. Two routers:
1. An HTTPS router on the `websecure` entrypoint with
`tls.certresolver=letsencrypt` (Dokploy preconfigures this resolver).
2. An HTTP router on the `web` entrypoint that uses a
`redirectscheme` middleware to forward everything to HTTPS.
- Set `traefik.docker.network=dokploy-network` and
`traefik.http.services.[name].loadbalancer.server.port=[PORT]` explicitly.
Don't rely on Traefik auto-detecting the port.
- Secrets: reference environment variables as `${VAR_NAME}` in the compose
file. The actual values will be set in Dokploy's Environment Variables
dashboard for that project, not in a committed .env file.
- Persistence: use named volumes for app data and database data, declared
in the top-level `volumes:` block. Use bind mounts only when I need to
reach files from the host filesystem (e.g. a media folder).
- Set `TZ=Asia/Manila` on services that respect it.
Output one complete compose file. After the file, list each environment
variable I need to set in Dokploy with a one-line description.
That prompt is the result of getting bitten a few times by AI defaults (publishing the database port to the host, hardcoding passwords inline, using :latest, forgetting the network label) and tightening the instructions each time. Feel free to keep tightening yours.
The output is a starting point. You will still need to read every line, fix the wrong port, double-check the env var names against the project’s docs, and decide whether the volumes match what you actually want. AI is great at boilerplate and bad at knowing what your specific service expects. Use it to skip the typing, not the understanding.
Pin versions. Always.
The fastest way to wreck a self-hosted setup is to use :latest and let a maintainer ship a breaking change at 3 AM. Pin the image tag to a specific version (postgres:17.2, not postgres:latest) and let your git commits decide when to upgrade. A bump becomes a pull request: change one line, push, watch the deploy, roll back if it breaks. That’s version control doing what version control is for.
The same goes for Dokploy itself, Traefik, anything else you run. The convenience of “automatically gets the latest” is a trap. The peace of “this exact tag, until I decide otherwise” is the goal.
Local AI coding tools make this workflow really nice
You can paste prompts into a chat app, copy the YAML out, and that works. But if you want this to feel pleasant, run an AI coding tool inside the git repo for the service. Then the model can read existing files, edit them in place, and you just review the diff before committing.
Pick whichever you like:
- Claude Code: Anthropic’s CLI. What I use day-to-day.
- Gemini CLI: Google’s, open source.
- Codex CLI: OpenAI’s CLI.
- cursor-agent: Cursor’s terminal agent. The CLI, not the editor.
The pattern is the same in all of them: open the folder, ask for what you want, review the changes, commit. Combined with the “compose files live in a git repo” rule, you get something close to a real CI/CD discipline without setting up CI/CD. Every config change has a diff, a date, and a reason. The blast radius is bounded.
One small thing that makes these CLIs much sharper for self-hosting work: install a domain-specific skill so the agent already knows Docker conventions before you start typing. For example:
npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill docker-expert
A skill is basically a system-prompt extension the agent loads automatically. The docker-expert skill primes it with Compose conventions, healthcheck patterns, networking gotchas, and the kind of taste you’d otherwise have to drill in by hand. There are similar skills for Traefik, Postgres, Ansible, and others in that repo. Worth browsing.
Build images in CI, not on the VPS
Related point that took me a couple of failed deploys to internalize: don’t build Docker images on the VPS itself. A CX22 has 4 GB of RAM, and any non-trivial Node app build (webpack, Vite, Next.js production build) will happily eat all of it and OOM mid-deploy.
The right pattern: build the image in CI (GitHub Actions is free for public repos, generous on private), push it to a registry (GitHub Container Registry is fine), and have Dokploy pull the prebuilt image. Your VPS only ever runs docker pull and docker compose up. Builds happen on someone else’s hardware with proper memory.
A minimal GitHub Actions workflow for this is about 30 lines. Ask one of the AI tools above to scaffold one for your specific repo and read the result. The pattern is simple enough that you’ll understand it after a couple of deploys.
Step 8: Dokploy
Dokploy is an open-source self-hosted PaaS. Think of it as a free Vercel/Heroku that runs on your own box. You connect a Git repo, it builds and deploys with Docker, gives you HTTPS certs via Let’s Encrypt, and shows you logs and stats in a UI.
Install:
curl -sSL https://dokploy.com/install.sh | sh
It’ll spit out a URL like http://<vps-public-ip>:3000 and tell you to open it to create an admin account.
Don’t open that URL on the public internet
This is the part most tutorials skip. That initial Dokploy admin page is plain HTTP on a high port, and for the brief window before you’ve created an admin account there’s nothing in front of it. The risk of someone stumbling onto it in that window is small, but it’s not zero, and there’s no reason to take even that small risk when you already have two safer paths to the same page.
Option A: reach it over Tailscale. Since the VPS is on your tailnet, you can hit the port through its private 100.x.x.x IP:
http://<vps-tailnet-ip>:3000
Open that in your laptop’s browser. The traffic goes inside the WireGuard tunnel, the port never appears on the public internet, and only your tailnet devices can reach it. This is what I do.
Option B: SSH tunnel (works without Tailscale, useful as a fallback). From your laptop:
ssh -L 3000:localhost:3000 -p 2200 bernard@<vps-public-ip>
While that SSH session is open, http://localhost:3000 on your laptop is forwarded over the encrypted SSH connection to port 3000 on the VPS. Same effect: the Dokploy port doesn’t have to be open to the world.
Either way, finish the admin setup, then immediately point a domain at the VPS (an A record at Cloudflare proxied through), tell Dokploy that’s your panel domain, and from then on you manage everything through dokploy.yourdomain.com with proper HTTPS. Adding an app is: paste GitHub repo URL, pick a compose file, deploy.
The walkthrough I learned from is this one. Worth an hour of your evening:
Dokploy docs: docs.dokploy.com.
A note on the alternatives: Coolify is the big one people compare it to. Both are good. I picked Dokploy because the UI felt cleaner and I liked that it leans on Docker Swarm under the hood instead of inventing its own orchestration. Try both if you have time. You won’t regret either choice.
Step 9: Notifications
I keep monitoring deliberately minimal. Dokploy already shows you per-container CPU and memory inside its dashboard, which is enough for small projects.
For “tell me when something breaks,” I pipe Dokploy notifications into a private Telegram channel. Build started, build failed, deployment finished, container restarted: all of it lands as a Telegram message on my phone. Setup is in Dokploy under Settings → Notifications: make a Telegram bot through @BotFather, grab the token, get your chat ID, paste both into Dokploy. About five minutes.
That’s it for monitoring on day one. If you find yourself wanting historical graphs or external uptime checks later, plenty of options exist. Don’t pre-install them.
Bonus: own a domain, and put Cloudflare in front of it
This is the one section worth reading even if you skim everything else. Owning a domain unlocks an outsized amount of nice-to-haves for the price of a cup of coffee per year. I run bernardtapiru.com and every self-hosted service of mine on this exact pattern, so I’m describing what I actually use, not what I think sounds clever.
Why a domain
For about $10/year you get something like yourname.com. From that one purchase you can hand out an unlimited supply of subdomains: dokploy.yourname.com for the admin panel, mealie.yourname.com for recipes, n8n.yourname.com for workflows, git.yourname.com for a private Forgejo, paste.yourname.com for a pastebin, etc. Each one is a clean URL to give people instead of an IP and a port. Each one gets its own automatic HTTPS cert through Traefik. Each one feels like a real product.
Pick a registrar that doesn’t try to gouge you on renewal. Cloudflare Registrar sells domains at wholesale (no markup) and they auto-renew at the same price. Porkbun and Namecheap are also fine. Avoid GoDaddy.
Why Cloudflare in front of your DNS
This is the part I really like. After you buy the domain, point its nameservers at Cloudflare and manage DNS there. The free tier gives you a stack of things that would individually cost money:
- Proxied DNS (the orange cloud). When you set an A record and turn on the proxy, the public DNS lookup returns a Cloudflare IP, not your VPS IP. Visitors hit Cloudflare; Cloudflare reaches your VPS over its own network. Your real Hetzner IP isn’t exposed in the proxied DNS response, which knocks out the laziest scanners. Important caveat: this is not a substitute for the SSH/UFW hardening you already did. Origin IPs can still leak through email headers from apps running on the box, error pages, historical DNS records, certificate-transparency logs, and direct IP scans of Hetzner ranges. Treat the origin as “discoverable by someone who’s actually looking.” Cloudflare proxy hides you from drive-by traffic; the hardened firewall is what protects you when someone actually finds the IP.
- Free SSL at the edge. Cloudflare terminates HTTPS for visitors using its own certs. Traefik on your VPS still uses Let’s Encrypt for the origin-to-Cloudflare leg. You end up with HTTPS everywhere, automatic, no manual cert renewal.
- Basic DDoS protection. If someone decides to flood your endpoint, Cloudflare absorbs it before it ever reaches Hetzner. The free tier handles “kid with a botnet” scale just fine.
- A WAF you can tighten if you need to. Block countries, block specific paths, rate-limit logins. The defaults are sane; the dials are there if you ever want them.
- Caching. Static assets get cached at the Cloudflare edge in 300+ cities. Your VPS only sees the misses. For a static portfolio site this is the difference between “fast” and “instant”.
- A genuinely clean UI. Compared to most DNS providers (looking at you, GoDaddy), Cloudflare’s dashboard is calm and well-organized. You’ll spend time in it. It might as well be pleasant.
A short setup recipe once you have the domain on Cloudflare:
- Add an A record for your VPS, e.g.
vps.yourname.com→ your Hetzner public IPv4. Orange cloud on. - For each service, add a CNAME pointing the subdomain at
vps.yourname.com. Orange cloud on. - In Dokploy’s compose label, use the subdomain as the Traefik host rule. Traefik on the VPS will request a Let’s Encrypt cert for that subdomain and serve it.
- In Cloudflare’s SSL/TLS settings, start with Full while Traefik is provisioning its first Let’s Encrypt cert (this can take a few minutes per new subdomain). Once the site loads cleanly, switch to Full (strict). The “strict” mode tells Cloudflare to verify the origin’s cert, which prevents a class of MITM attacks people forget about. Setting it to strict before the cert exists will give you a confusing 526 error. Start with Full, upgrade after.
I’d put “buying a domain and putting it behind Cloudflare” near the top of the highest-leverage learning purchases a student can make. It teaches you DNS, TLS, reverse proxying, and edge caching all at once, and the bill at the end of the year is less than dinner.
Step 10: Backups (do this before you need to)
This is the part everyone skips and regrets. Two layers worth thinking about.
Layer 1: your config and code. Every compose file in a git repo, pushed to GitHub or a private remote. This is free, automatic if you commit regularly, and gives you full history. If the VPS is gone, you re-clone the repos onto a fresh box and the deployments come back. This layer matters more than people realize. It’s also the layer most people already have for free.
Layer 2: your data. The databases, uploaded files, anything users (or you) generated that isn’t in git. I use Backblaze B2. The free tier is 10 GB and the egress pricing is much friendlier than AWS S3. A cron job runs pg_dump (or a mariadb-dump) every night, gzips it, and rclone copys it to B2. Takes 10 minutes to wire up and saves you a bad afternoon someday.
On Hetzner snapshots: I don’t use them. They cost about 20% of the server’s monthly price and they freeze the whole disk image. Useful if you bork the OS in a way you can’t undo. But if you already have (a) compose files in git and (b) database dumps off the box, you’ve covered the failure modes that actually matter. The remaining case (OS-level corruption with no way back) is rare enough that I’d rather spend the snapshot money on more B2 storage. Your call. If you’re nervous and the few dollars don’t bother you, turn snapshots on and forget about them.
If you want to go further once the basics feel boring: write Terraform for your cloud resources. The Hetzner provider lets you declare your server, firewall, and DNS records as code. I use this approach for the production-sensitive deployments I work on, and the discipline of “no clicking, only commits” is genuinely worth the upfront effort. For a personal box, it’s overkill until you have two or three of them.
Where to learn more
- Hetzner Cloud docs: docs.hetzner.com/cloud. Surprisingly readable.
- Julia Evans’ zines and blog: jvns.ca. The best plain-English explainer of how Linux networking, DNS, and processes actually work. Three of her posts a week for a month teaches you more than most courses.
- The Linux Foundation’s “Intro to Linux” on edX: free, structured, fills in the gaps you didn’t know you had.
- Tailscale’s blog: tailscale.com/blog. Their engineers write well and explain why their product exists, which teaches you a lot about networking by accident.
- Dreams of Code (the channel from the video above): I subscribe for the self-hosting and Dokploy walkthroughs, but the back catalog goes way wider than that. Genuinely good coverage of coding, DevOps, Linux, Go, and developer tooling. One of the few channels where the explanations hold up if you watch them twice.
- Dokploy docs and Discord: docs are fine, the Discord is where you’ll find actual answers to weird issues.
What you can actually run on this
Past a personal site or a side project, the same box becomes a place to swap out subscription apps for self-hosted equivalents you own. A short list of what mine does day to day:
- Your own digital library. Audiobookshelf for audiobooks, Booklore or Calibre-Web for ebooks. Accessible from any device you own, without Amazon or Audible deciding what stays in your account.
- A budget app that’s yours, accessible anywhere. Actual Budget gives you envelope-style budgeting that syncs through your VPS. No monthly fee. No company quietly mining your transaction history.
- A free automation platform with no per-execution caps. n8n on your own VPS is Zapier without the per-workflow limits or pricing tiers. Telegram bots, calendar workflows, email parsing, GitHub triggers, all stitched together for the cost of one container.
- Recipes, notes, invoices, remote desktop, photo backup, the daily-tool stack, each replaced with an open-source equivalent. The pattern: pay once for the box, replace whatever monthly subscriptions you can stomach moving off.
The economics work fast. Every subscription you replace with a self-hosted equivalent costs less than the monthly box.
A future post is going to walk through the specific apps I run on this VPS, why I picked each one over the alternatives, and the gotchas I hit getting them deployed cleanly through Dokploy. If there’s a particular slot you’re curious about (note-taking, file sync, photos, password vault, something else), let me know and I’ll prioritize it.
What this gets you
A box that costs about the price of a McDonald’s meal per month, sits behind two firewalls and a private mesh network, runs whatever Dockerized app you throw at it, and pings your phone on Telegram when something breaks. Enough to host a portfolio, a few side projects, a Discord bot, all the apps above, and whatever else you want to try.
It is also enough to learn on. Every step in this guide maps to a concept (SSH, public-key crypto, NAT traversal, firewalls, reverse proxies, containers, DNS) that you will see again in every job you ever take. Setting up your own VPS once teaches you more than ten tutorials about “the cloud.”
If you get stuck on any step, message me. Easier than debugging it alone.