· Jack Lee · Tutorials  · 9 min read

The Practical Guide to Self-Hosting Web Applications on Hetzner VPS with Dokploy

A comprehensive hands-on guide to deploying web apps with a low-cost and simple self-managed solution.

A comprehensive hands-on guide to deploying web apps with a low-cost and simple self-managed solution.

While working in the industry over the years, I’ve come to realize that developing ML code is just a small part of a much larger system when it comes to deploying real-world applications. Google’s 2015 paper, Hidden Technical Debt in Machine Learning Systems, remains just as relevant today; highlighting how infrastructure, security, and scalability often take center stage in production systems. This realization pushed me to develop a more well-rounded full-stack skillsets, bridging the gap between model development and real-world deployment.

In this guide, I’ll walk you through how to deploy a web application, whether it’s a standard frontend or an ML-powered backend, on Hetzner VPS using Dokploy and GitHub Container Registry, while securing it properly with Cloudflare and best practices; alongside with CI/CD workflows using GitHub Actions. If you’re an ML engineer (or any developer) looking to build production-ready, self-hosted applications without the complexities and cost of AWS, GCP, or Azure, this is for you.

Table of Contents

Step 1: Buying a Domain & Setting Up Cloudflare DNS

  1. Purchase a domain from Porkbun, Namecheap, or any other registrar.

    Porkbun Domain Interface
    Porkbun Domain Interface
  2. Update your domain’s nameservers to Cloudflare.

    • Add the purchased domain to Cloudflare
    Cloudflare Starting Page
    Cloudflare Starting Page
    • Select the free plan
    Cloudflare Plans
    Cloudflare Plans
    • Check and verify the migrated DNS records
    Cloudflare DNS Records
    Cloudflare DNS Records
    • Copy Cloudflare nameservers (NS)
    Cloudflare NS
    Cloudflare NS
    • Swap existing Porkbun’s NS to Cloudflare’s
    Porkbun NS
    Porkbun NS
  3. Configure Cloudflare for better security and site optimization.

    Security

    • Navigate to “SSL/TLS > Overview”, set encryption mode to Full (Strict)
    • Navigate to “SSL/TLS > Edge Certificates”, enable “Always Use HTTPS”

    CDN / Caching

    • Navigate to “Caching > Cache Rules”, select “Create rule”
    • Select “Cache Everything”, ideal fo static sites
    • Set rule name, use default settings, and click “Deploy”
    Cloudflare Caching
    Cloudflare Caching

Step 2: Setting Up a Hetzner VPS

  1. Register a Hetzner account.

  2. Create a new project, navigate to “Servers” and click “Add Server”.

  3. Utilize the following configurations as reference for server creation:

    Location

    • Falkenstein (eu-central)

    Image

    • Ubuntu 24.04

    Type

    • Shared vCPU
    • x86 (Intel/AMD)
    • CX22 (2 vCPUs, 4GB RAM, 40 GB SSD)

    SSH Keys

    • Create an SSH key, one that has a passphrase for an added layer of security, with the following terminal command:

      ssh-keygen -t ed25519 -C "Hetzner-Access" -f ~/.ssh/hetzner_access
      
    • Copy the contents of the public key (”~/.ssh/hetzner_access.pub”) and pass it into the SSH Keys section

    Firewalls

    • Create firewall rules with the following ports:

      PortDescription
      7963Custom SSH port
      80Standard port for HTTP connections
      443Standard port for HTTPS connections
      3000Temporary port to connect to Dokploy UI

    Cloud Config

    The following cloud-init script runs automatically when the server is created successfully, it has a couple of essential features:

    • User creation with authorized public SSH keys
    • Automatic security updates with minimal service distruption
    • Hardened SSH with custom SSH port and configurations
    • Strict firewall rules that only allows necessary ports
    • Fail2Ban installation to protect against brute-force attacks
    • Blocked ICMP Echo Requests to hide from ping scans
    • Dokploy installation with root access
    #cloud-config
    users:
      - name: johndoe
        groups: users, admin
        sudo: ALL=(ALL) NOPASSWD:ALL
        shell: /bin/bash
        ssh_authorized_keys:
          - <public_ssh_key>
    
    packages:
      - fail2ban
      - ufw
      - unattended-upgrades
      - apt-listchanges
    
    package_update: true
    package_upgrade: true
    
    write_files:
      - path: /etc/apt/apt.conf.d/20auto-upgrades
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";
          APT::Periodic::Download-Upgradeable-Packages "1";
          APT::Periodic::AutocleanInterval "7";
    
      - path: /etc/apt/apt.conf.d/50unattended-upgrades
        content: |
          Unattended-Upgrade::Automatic-Reboot "true";
          Unattended-Upgrade::Automatic-Reboot-Time "02:00";
          Unattended-Upgrade::Remove-Unused-Dependencies "true";
          Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
          Unattended-Upgrade::Allowed-Origins {
              "${distro_id}:${distro_codename}";
              "${distro_id}:${distro_codename}-security";
              "${distro_id}ESMApps:${distro_codename}-apps-security";
              "${distro_id}ESM:${distro_codename}-infra-security";
          };
          Unattended-Upgrade::MinimalSteps "true";
          Unattended-Upgrade::SyslogEnable "true";
          Unattended-Upgrade::OnlyOnACPower "false";
    
    runcmd:
      # Configure unattended-upgrades
      - systemctl enable unattended-upgrades
      - systemctl start unattended-upgrades
      
      # Install Dokploy BEFORE restricting root access
      - curl -sSL https://dokploy.com/install.sh | sh
      
      # Configure Fail2Ban
      - printf "[sshd]\nenabled = true\nbanaction = iptables-multiport" > /etc/fail2ban/jail.local
      - systemctl enable fail2ban
      
      # Harden SSH Configuration & Change SSH Port
      - sed -i -e '/^\(#\|\)PermitRootLogin/s/^.*$/PermitRootLogin no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)PasswordAuthentication/s/^.*$/PasswordAuthentication no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)KbdInteractiveAuthentication/s/^.*$/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)ChallengeResponseAuthentication/s/^.*$/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)MaxAuthTries/s/^.*$/MaxAuthTries 2/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)AllowTcpForwarding/s/^.*$/AllowTcpForwarding no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)X11Forwarding/s/^.*$/X11Forwarding no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)AllowAgentForwarding/s/^.*$/AllowAgentForwarding no/' /etc/ssh/sshd_config
      - sed -i -e '/^\(#\|\)AuthorizedKeysFile/s/^.*$/AuthorizedKeysFile .ssh\/authorized_keys/' /etc/ssh/sshd_config
      - sed -i 's/^#\?Port 22/Port 7963/' /etc/ssh/sshd_config
      - sed -i '$a AllowUsers johndoe' /etc/ssh/sshd_config
      
      # Configure Firewall (UFW)
      - ufw default deny incoming
      - ufw default allow outgoing
      - ufw allow 7963/tcp  # Allow SSH on new port
      - ufw allow 80/tcp   # Allow HTTP for web access
      - ufw allow 443/tcp  # Allow HTTPS for secure web access
      - ufw allow 3000/tcp # Allow Dokploy web interface
      - sed -i '/# ok icmp codes for INPUT/a -A ufw-before-input -p icmp --icmp-type echo-request -j DROP' /etc/ufw/before.rules
      - ufw --force enable
      
      # Restart Services
      - systemctl restart sshd
      - ufw reload
      
      # Final reboot
      - reboot
    

    In the event that cloud-init script is not supported by your preferred VPS provider, here is an alternative with a simple bash script that can be executed by any Linux distro:

    # METHOD 1 - Save to local file and run
    curl -O https://gist.githubusercontent.com/JackLeeJM/3b30f9b2af1f7c0c86a34864f3cc082e/raw/cloud-config.sh
    chmod +x cloud-config.sh
    sudo ./cloud-config.sh your-username "your-ssh-public-key" 7963
    
    # METHOD 2 - One-liner with direct execution from pipe
    curl -sSL https://gist.githubusercontent.com/JackLeeJM/3b30f9b2af1f7c0c86a34864f3cc082e/raw/cloud-config.sh | bash -s -- your-username "your-ssh-public-key" 7963
    
  4. Click “Create & Buy now” after finalizing the server name.

  5. Wait about 5 to 10 minutes for the server’s cloud-config setup to complete.

Step 3: Setting Up Dokploy

  1. Access to Dokploy UI with “http://your-hetzner-server-ip:3000” and register an account with dummy credentials.
  2. Navigate to “Settings > Web Server” from the left panel, and fill in the Domain field with a subdomain address of your purchased domain.
    Dokploy Server Domain
    Dokploy Server Domain
  3. Go to Cloudflare’s dashboard, select the domain you have registered earlier, navigate to DNS and change the records by assigning the Hetzner VPS IP address to the main domain, as well as the subdomain to manage the Dokploy application.
    Cloudflare DNS Records - Final
    Cloudflare DNS Records - Final
  4. Now that the Dokploy app is properly secured with HTTPS through Cloudflare, we can proceed to change the credentials. Navigate to “Settings > Profile”, change to a much stronger password (BitWarden) and enable 2FA (Microsoft Authenticator) to add an additional layer of security.
  5. While still in the Profile tab, generate a token to access the API/CLI, this will be used in Step 4.
  6. Navigate to “Home > Projects”, create a project, then create a service for “Application”.
  7. Since the Dokploy app is accessible via “https://dokploy.example.com”, we can now remove the temporary firewall rule of port 3000 in both Hetzner Firewall and UFW.
    • Navigate to Hetzner console’s Firewall page and remove the inbound rules for Port 3000

      Hetzner Firewall Rules
      Hetzner Firewall Rules
    • SSH into Hetzner VPS via terminal and remove Port 3000 from UFW rulesets

      # Login to VPS
      ssh -i ~/.ssh/hetzner_access johndoe@your_vps_ip_address -p 7963
      
      # Removes firewall rule and restart UFW service
      sudo ufw delete allow 3000/tcp
      sudo ufw reload
      

Step 4: Setting Up GitHub Container Registry

The example web application that we will be demonstrating in this tutorial is Astroplate, an open-source Astro project, with a convenient Dockerfile to boot.

  1. Clone the GitHub repository

    git clone https://github.com/zeon-studio/astroplate.git
    
  2. Publish the repository to GitHub, and navigate to “Settings > Security > Actions” to add the necessary secrets that will be used by the GitHub Action workflows.

    • DOKPLOY_AUTH_TOKEN

      Retrieve the generated token from Step 4’s Item No. 5, where it is found in the Dokploy UI’s Profile tab.

    • DOKPLOY_APPLICATION_ID

      The application_id can be determine via a curl command or the Dokploy UI

      # Curl command
      # Outputs a JSON object that lists all projects and services
      # Eyeball the output to identify the application_id
      curl -X 'GET' \
        'https://dokploy.example.com/api/project.all' \
        -H 'accept: application/json'
        -H 'Authorization: Bearer <DOKPLOY_AUTH_TOKEN>'
      
      # Dokploy UI
      1. Navigate to "Home > Projects".
      2. Select the project you have created.
      3. Select the service application you have created.
      4. Check the web address URL, which should look like:
         "https://dokploy.example.com/dashboard/project/<project_id>/services/application/<application_id>"
      5. Copy the application_id directly from URL.
      
    • DOKPLOY_URL

      The Dokploy UI dashboard URL without the trailing backslash - “https://dokploy.example.com

  3. Create a folder structure of .github/workflows in the project directory, then create a YAML file called publish-ghcr.yaml and paste in the following content:

    # .github/workflows/publish-ghcr.yaml
    name: Docker Image CI/CD for GHCR
    
    on:
      pull_request:
        branches:
          - main
      push:
        branches:
          - main
    
    jobs:
      build_and_publish:
        runs-on: ubuntu-latest
        permissions:
          contents: read
          packages: write
    
        steps:
          - name: Checkout GitHub Action
            uses: actions/checkout@v4
    
          - name: Login to GitHub Container Registry
            uses: docker/login-action@v3
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
    
          - name: Set Repository Owner Name to Lower Case
            run: |
              echo "REPO_OWNER_LC=${OWNER,,}" >> ${GITHUB_ENV}
            env:
              OWNER: '${{ github.repository_owner }}'
    
          - name: Build Docker Image and Push to GHCR
            run: |
              docker build . --tag ghcr.io/$REPO_OWNER_LC/astroplate:latest
              docker push ghcr.io/$REPO_OWNER_LC/astroplate:latest
    
          - name: Dokploy Deployment
            uses: benbristow/[email protected]
            with:
              auth_token: ${{ secrets.DOKPLOY_AUTH_TOKEN }}
              application_id: ${{ secrets.DOKPLOY_APPLICATION_ID }}
              dokploy_url: ${{ secrets.DOKPLOY_URL }}
    

    Here’s a brief description of the workflow configurations:

    • Workflow is triggered when pushed to main branch
    • Jobs are given permission to read content and write packages (Essential for GHCR)
    • Login to GHCR using the ephemeral secrets.GITHUB_TOKEN
    • Letter case conversion of GitHub Username to lowercase for consistency
    • Build docker image with specified tag and push to ghcr.io
    • A Dokploy webhook is triggered to pull the new docker image
  4. Commit and sync the changes to GitHub, the workflow will be triggered and the docker image of “astroplate” should be uploaded to GitHub Container Registry. The docker image can be found in the repository’s Packages.

Step 5: Setting up Dokploy Application with CI/CD

  1. Go to GitHub’s Developer settings, create a classic Personal Access Token (PAT) with permission for “write:packages”, name it GHCR_PAT.
  2. Navigate to “Projects > Project-Name > Service-App-Name”, under the General tab of “Provider”, select “Docker” and fill in the details.
    • Docker Image - ghcr.io/johndoe/astroplate:latest
    • Registry URL - ghcr.io
    • Username - johndoe (GitHub Username)
    • Password - *** (GHCR_PAT)
    Dokploy App Provider
    Dokploy App Provider
  3. Navigate to “Domains” tab and fill in the details.
    Dokploy App Domain
    Dokploy App Domain
  4. Go back to the “General” tab, click “Deploy” and ensure that “Autodeploy” is enabled.
  5. Wait for Dokploy to pull the docker image and deploy it, you can check the completion of the deployment status from the “Deployments” tab.
  6. Visit your purchased domain of “https://example.com” and it should show the Astroplate frontend.

CI/CD Mechanism

  • Whenever new code is pushed to Github
  • GitHub Action workflow “publish-ghcr.yaml” will be triggered
  • A new docker image will be built and pushed to GitHub Container Registry
  • The Dokploy webhook will be triggered to notify the Dokploy app for automated deployment
  • New changes should be reflected on the website shortly after

Conclusion

It has been a rather fulfilling journey of growth on the road to an all-encompassing fullstack skillset, both in developing this website using the abovementioned methodologies and documenting the whole process to share it with the world. I hope this guide serve as a cornerstone to further empower you to build and deploy web applications with confidence!

Reference

  • Hetzner - Basic Cloud Config (Source)
  • Hetzner - Securing the SSH service (Source)
  • Hetzner - How to Keep Your Ubuntu Server Safe (Source)
  • Dokploy - Going Production (Source)
  • GitHub - Working with the Container registry (Source)
  • Pushing container images to GitHub Container Registry with GitHub Actions (Source)
Back to Blog

Related Posts

View All Posts »