Skip to main content

Migrate from Virtualmin

This guide walks you through migrating PHP+MySQL sites from a Virtualmin server to Kuploy. The process uses the kuploy-migrate toolkit to automate file copying, database dumps, and Dockerfile generation.

Overview

The migration flow:

  1. Export — run the migrate script on your Virtualmin server to push site files and database dumps to a GitHub repo
  2. Deploy — create projects, databases, and applications in Kuploy pointing to that repo
  3. Switch DNS — update A records to point to your Kuploy cluster

Each Virtualmin site becomes a Kuploy project with its own MariaDB database and application deployment.

Prerequisites

  • A Virtualmin server with root access
  • git, curl, rsync, mysql, mysqldump installed on the server
  • A GitHub Personal Access Token with repo scope
  • A Kuploy instance with a connected GitHub provider
  • A container registry configured in Kuploy (Settings → Registry)

Phase 0: Prepare the Base Image

Kuploy-migrate uses a shared base Docker image for all PHP sites. Build and push it once:

git clone https://github.com/ceduth/kuploy-migrate
cd kuploy-migrate

docker build --platform linux/amd64 -t yourregistry/php-legacy:8.1 -f base-Dockerfile .
docker push yourregistry/php-legacy:8.1

The base image includes PHP 8.1, Apache with mod_rewrite and AllowOverride All, mysqli/PDO extensions, Composer, and sensible php.ini defaults (64M uploads, 256M memory, 300s execution time).

tip

If your cluster runs on ARM nodes, build multi-platform:

docker buildx build --platform linux/amd64,linux/arm64 --push -t yourregistry/php-legacy:8.1 -f base-Dockerfile .

Phase 1: Export Sites from Virtualmin

Run the Migration Script

On your Virtualmin server:

export GITHUB_PAT=ghp_your_token
cd /path/to/kuploy-migrate
chmod +x virtualmin/migrate.sh

# Dry run first
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite" \
--dry-run

# Run for real
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite"

What the Script Does

For each site, the script:

  1. Parses /etc/webmin/virtual-server/domains/* for database credentials, domain names, and PHP mode
  2. Copies public_html/ with exclusions (logs, archives, mail, phpMyAdmin)
  3. Detects PHP apps outside public_html/ (by checking for artisan or vendor/autoload.php)
  4. Copies the app root preserving the original directory name
  5. Dumps the MySQL database using per-site credentials
  6. Generates a minimal Dockerfile
  7. Saves credentials to a local file (never committed to Git)
  8. Pushes everything to the GitHub repo

Repository Structure

The script creates this structure in your GitHub repo:

virtualmin-sites/
├── mysite/
│ ├── Dockerfile
│ ├── public_html/
│ ├── mysite/ ← PHP app root (if detected)
│ ├── db/mysite.sql ← MySQL dump
│ └── site.json ← Metadata
└── .gitignore

PHP App Detection

The script checks for code directories outside public_html/ at ~/<site>/<site>/, ~/<site>/app/, or ~/<site>/laravel/. It looks for either artisan (Laravel) or vendor/autoload.php (any Composer app).

The original directory name is preserved so that require() paths in index.php continue to work. For example, if your index.php does require('../mysite/vendor/autoload.php'), the Dockerfile copies to /var/www/mysite/.

Custom app directory: If your PHP app root has a non-standard name (e.g., sita instead of the site name), use the --app-dir flag:

./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite" \
--app-dir "mysite:sita"

This tells the script to look for the app at ~/<site>/sita/ in addition to the default locations. The directory name is preserved in the Dockerfile for path compatibility.

Dependency handling:

  • If the app has a composer.json, vendor is excluded from Git and the Dockerfile runs composer install --no-dev at build time
  • If there is no composer.json, vendor is committed as-is

Phase 2: Deploy to Kuploy

You can deploy sites either via the Site Import API (recommended for batch migrations) or manually through the dashboard.

The Site Import API creates the project, database, application, environment variables, and domain in a single API call per site.

Setup:

  1. Generate an API key from Settings → API Keys
  2. Note your Organization ID and GitHub Git Provider ID from the dashboard

Import all sites:

export API_KEY="your-api-key"
export KUPLOY_URL="https://console.example.com"
export ORG_ID="org_xxx"
export GITHUB_ID="gh_xxx"
export REPO="yourorg/virtualmin-sites"

for site_dir in ${REPO_DIR}/*/; do
site=$(basename "$site_dir")
domain=$(jq -r '.domain' "$site_dir/site.json")
db_name=$(jq -r '.db_mysql' "$site_dir/site.json")
db_user=$(jq -r '.mysql_user' "$site_dir/site.json")
db_pass=$(grep "^$site " "$CREDS_FILE" | awk '{print $4}')

curl -s -X POST "$KUPLOY_URL/api/site-import" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"organizationId\": \"$ORG_ID\",
\"name\": \"$site\",
\"domain\": \"$domain\",
\"database\": {
\"type\": \"mariadb\",
\"name\": \"$db_name\",
\"user\": \"$db_user\",
\"password\": \"$db_pass\"
},
\"source\": {
\"type\": \"github\",
\"repo\": \"$REPO\",
\"branch\": \"main\",
\"buildPath\": \"/$site\",
\"buildType\": \"dockerfile\",
\"githubId\": \"$GITHUB_ID\"
},
\"port\": 80
}" | jq .
done

The API automatically sets DB_HOST, DB_DATABASE, DB_USERNAME, and DB_PASSWORD on the application. For Laravel sites, add extra env vars via the envVars field:

"envVars": {
"APP_KEY": "base64:xxx",
"APP_ENV": "production",
"APP_DEBUG": "false"
}
tip

Get the APP_KEY from the original server:

grep APP_KEY /home/mysite/mysite/.env

Import the Database Dump

After the Site Import API creates the database container and it's running, import the SQL dump:

# Find the pod name
kubectl get pods -n project-mysite

# Import the dump (use -i, not -it, when piping stdin)
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite < mysite/db/mysite.sql

# Verify tables were created
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite -e "SHOW TABLES;"
info

MariaDB containers use the mariadb command, not mysql. Get the root password from the pod environment:

kubectl exec -i <mariadb-pod> -n project-mysite -- env | grep MARIADB_ROOT

Option B: Manual Setup (Dashboard)

If you prefer to set up each site manually:

  1. Create a Project — click New Project, name it after the site
  2. Create a MariaDB DatabaseAdd ResourceDatabaseMariaDB, set name/user/password
  3. Create an ApplicationAdd ResourceApplication, set source to GitHub, repo yourorg/virtualmin-sites, branch main, build path /<site>, build type Dockerfile, port 80
  4. Set Environment VariablesDB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD (and APP_KEY etc. for Laravel)
  5. Add Domain — go to Domains tab, add your domain

Add a Custom Domain

If you used the API, the domain is already configured. If manual:

  1. Go to the Domains tab
  2. Add your domain (e.g., mysite.com)
  3. Kuploy automatically provisions a TLS certificate via cert-manager

Test Before Switching DNS

Verify the deployment works before updating DNS:

# Add to /etc/hosts on your local machine
<k8s-ingress-ip> mysite.com

Get the ingress IP:

kubectl get svc -n ingress-nginx

The EXTERNAL-IP on the ingress-nginx-controller service is your load balancer address.

DNS Migration Strategy

Important

Do not include a domain in the Site Import API call if DNS still points to your old server. Domain creation triggers SSL certificate provisioning (Let's Encrypt HTTP-01 challenge), which will fail if the domain doesn't resolve to your Kuploy ingress IP.

Instead:

  1. Import the site without a domain — deploy and verify it works first
  2. Update DNS to point to the Kuploy ingress IP
  3. Add the domain via the Kuploy dashboard once DNS propagates

Get your Kuploy ingress IP:

kubectl get svc -n ingress-nginx
# Look for the EXTERNAL-IP on ingress-nginx-controller

Test Before Switching DNS

Verify the deployment works before updating DNS:

# Add to /etc/hosts on your local machine
<k8s-ingress-ip> mysite.com

Update DNS

Once verified, update the A record for your domain to point to the Kuploy ingress IP.

If DNS is managed by an external registrar (Enom, Namecheap, etc.), update the A record in their dashboard.

If DNS is managed by Virtualmin, the web UI validates that A records match the server's own IP and will reject changes. Two workarounds:

Option A — Uncheck validation (Web UI):

  1. Go to Server Configuration → DNS Records for the domain
  2. Change the A record for the bare domain (e.g., sitagroupgn.com.) to your Kuploy ingress IP
  3. Uncheck "Validate new records?" at the bottom
  4. Click Save

Option B — CLI (bypasses validation entirely):

KUPLOY_IP="<kuploy-ingress-ip>"

virtualmin modify-dns --domain mysite.com --remove-record "mysite.com. A"
virtualmin modify-dns --domain mysite.com --add-record "mysite.com. A $KUPLOY_IP"
virtualmin modify-dns --domain mysite.com --remove-record "www.mysite.com. A"
virtualmin modify-dns --domain mysite.com --add-record "www.mysite.com. A $KUPLOY_IP"
tip

Only update the A records for the bare domain and www. Leave all other records unchanged — especially MX, mail, webmail, SPF, and DMARC records if the Virtualmin server still handles email.

caution

Leave MX, mail, webmail, SPF, and autodiscover records unchanged — they should keep pointing to the Virtualmin server if it still handles email.

Add the Domain in Kuploy

After DNS propagates (check with dig +short mysite.com A):

  1. Go to Projects → mysite → Domains
  2. Add mysite.com with HTTPS enabled
  3. Kuploy automatically provisions a TLS certificate via Let's Encrypt

Alternatively, if you used the Site Import API and the import is partial or failed:

  1. Go to Import Sites
  2. Click the pencil icon on the import card to add the domain
  3. Click Retry — the import picks up where it left off

Phase 3: Batch Remaining Sites

Run the migrate script for multiple sites at once:

./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "site1 site2 site3 site4 site5"

Then repeat the Kuploy setup (project, database, application, env vars, domain) for each site.

Troubleshooting

Import shows partial with "domain_creation: HTTP request failed"

The domain step failed, likely because DNS doesn't point to the Kuploy cluster yet. The site's project, database, and application were created, but the domain and deployment were not.

Fix:

  1. Go to Import Sites
  2. Click the pencil icon on the import card
  3. Clear the Domain field
  4. Click Save, then Retry

This retries without domain creation. Add the domain later via the dashboard after migrating DNS.

Import shows success but the site isn't reachable

success means all resources were created and the build was triggered, but the build and deployment are asynchronous. Check:

  1. Go to Projects → mysite → Application → Deployments to see build status
  2. If the build failed, check the deployment logs for errors
  3. If the build succeeded but no pod is running, click Deploy to trigger a redeploy

Retry button is spinning with no progress

The import is waiting for a long-running step (build, K8s resource creation). This can happen if your cluster's API is slow.

  • Wait — builds can take up to 30 minutes for large sites
  • If it stays stuck, refresh the page to reset the UI, then check the import status

Build fails with "failed to read dockerfile: is a directory"

The buildPath in the import points to a subdirectory (e.g., /mysite) but the builder couldn't find the Dockerfile. Ensure:

  • The Dockerfile exists at <buildPath>/Dockerfile in your repository
  • The build path starts with / (e.g., /mysite, not mysite)

Database dump import fails

If kubectl exec hangs or returns errors:

# Verify the pod is running
kubectl get pods -n project-mysite

# Check the MariaDB root password
kubectl exec -i <mariadb-pod> -n project-mysite -- env | grep MARIADB_ROOT

# Ensure you use -i (not -it) when piping stdin
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite < mysite/db/mysite.sql

Caveats

Email

Virtualmin manages email (Postfix/Dovecot) per domain. Kuploy does not handle email. Options:

  • Keep Virtualmin running solely for email (strip web hosting, keep MX records)
  • Migrate to an external email provider (Google Workspace, Zoho, Mailgun)

phpMyAdmin

The migration script strips bundled phpMyAdmin from each site. Deploy one shared phpMyAdmin instance as a separate Kuploy application if needed.

PHP Mode

Virtualmin uses php_mode=fcgid (FastCGI). The base image uses mod_php (Apache module). This works for the vast majority of sites. If a site relies on per-user PHP pools, it may need adjustment.

Cron Jobs

Virtualmin cron jobs need to be migrated manually. Check existing crontabs:

crontab -u mysite -l

Set these up as Kuploy schedules or Kubernetes CronJobs.

Large Sites

GitHub has a soft limit of ~1GB per repo and 100MB per file. If a site has very large media files, consider serving those from S3 or object storage instead of including them in the Git repo.

Script Reference

FlagRequiredDescription
--patYesGitHub Personal Access Token
--repoYesTarget repo in owner/name format (created if missing)
--sitesYesSpace-separated list of site directories under /home/
--site-pathNoMap site to custom home path (format: site:/path, repeatable)
--app-dirNoMap site to custom PHP app directory name (format: site:dirname, repeatable)
--base-imgNoBase Docker image (default: ceduth/php-legacy:8.1)
--dry-runNoPreview without executing

The script saves database credentials to /tmp/virtualmin-migrate/credentials.txt (local only, never committed). Use these when setting up databases in Kuploy.