Deploy multiple applications to a single Dokku server with centralized configuration. Import existing apps from a server, migrate between servers, or set up fresh deployments.
- Multi-app orchestration - Deploy multiple independent repos from one config
- Hierarchical configuration - Parent settings cascade to deployments, child overrides parent
- Smart deployment - Skips unchanged apps (compares git commits)
- Secrets management - Hierarchical
.envfiles, gitignored - Pre/post deploy hooks - Run scripts before/after deployment
- Tag-based filtering - Deploy subsets by tag (
--tag staging,--tag api) - PostgreSQL auto-setup - Opt-in automatic database provisioning
- Let's Encrypt SSL - Opt-in automatic SSL certificate provisioning
- MySQL service exposure - Optional
mysql:exposeautomation for local-only DB access - Storage mounts, ports, domains - Full Dokku configuration support
- Server import/migration - Import all apps from existing server, migrate to new server
- Backup & Restore - Backup/restore PostgreSQL databases and storage mounts with xz compression
Configure your Dokku server in ~/.ssh/config:
Host <ssh-alias>
HostName <your-server-ip>
User root
IdentityFile ~/.ssh/<your-key>
IdentitiesOnly yes
Then use <ssh-alias> as the ssh_alias in your config.json.
You can run this from any project by symlinking it into your PATH:
mkdir -p ~/bin
ln -sf /absolute/path/to/dokku-multideploy/deploy.sh ~/bin/deploy
chmod +x /absolute/path/to/dokku-multideploy/deploy.shThen use it inside any app folder that has config.json:
deploy --dry-run
deploy --syncNotes:
- If you use fish shell and
deployis not found, add~/binto fish paths:set -Ux fish_user_paths ~/bin $fish_user_pathsThen restart your shell and verify with:command -v deploy - By default,
deploylooks forconfig.jsonnext to the invoked script/symlink, then falls back to$PWD/config.json. - You can always override explicitly:
CONFIG_FILE=$PWD/config.json deploy --dry-run
# 1. Clone this repo
git clone https://github.com/benmarten/dokku-multideploy.git
# 2. Import all apps from your existing server to a separate directory
./dokku-multideploy/deploy.sh --import ./apps --ssh <ssh-alias>
# 3. Backup databases and storage mounts
cd apps
ln -s ../dokku-multideploy/deploy.sh .
./deploy.sh --backup
# 4. Update config.json with new server details
# Change ssh_host and ssh_alias to new server
# 5. Deploy everything to new server
./deploy.sh --dry-run # Preview first
./deploy.sh
# 6. Restore backups on new server
SSH_HOST=<new-server> ./restore.sh backups/<timestamp># 1. Clone and set up your project
git clone https://github.com/benmarten/dokku-multideploy.git
cd <your-project>
ln -s <path-to>/dokku-multideploy/deploy.sh .
cp <path-to>/dokku-multideploy/config.example.json config.json
# Edit config.json with your apps
# 2. Add secrets (optional)
mkdir -p .env
echo "DATABASE_PASSWORD=secret" > .env/api.example.com
# 3. Deploy!
./deploy.shAlready have apps running on a Dokku server? Import everything:
# Import all apps from your Dokku server
./deploy.sh --import ./apps --ssh <ssh-alias>
# Import without secrets (env vars)
./deploy.sh --import ./apps --ssh <ssh-alias> --no-secretsThis will:
- Clone all app git repos to
./apps/<domain>/ - Generate
config.jsonwith settings (domains, ports, storage, postgres, letsencrypt) - Export all env vars to
.env/files (not config.json, since we can't distinguish secrets)
Then symlink deploy.sh and you're ready:
cd ./apps
ln -s <path-to>/dokku-multideploy/deploy.sh .
./deploy.sh --dry-runMigrate all apps to a new server:
# 1. Import from current server (if not already done)
./deploy.sh --import ./apps --ssh <old-server>
# 2. Set up new server with Dokku
ssh <new-server> "wget -NP . https://dokku.com/install/v0.34.4/bootstrap.sh && sudo bash bootstrap.sh"
# 3. Update SSH config for new server
# Edit ~/.ssh/config to add <new-server> alias
# 4. Update config.json
# Change ssh_host and ssh_alias to new server
# 5. Deploy everything to new server
./deploy.shThe script will create all apps, configure domains, env vars, storage mounts, ports, postgres, and letsencrypt on the new server.
your-project/
├── deploy.sh # Symlink to dokku-multideploy/deploy.sh
├── restore.sh # Symlink to dokku-multideploy/restore.sh
├── config.json # Your deployment configuration
├── .env/ # Secret environment variables (gitignored)
│ ├── _api # Shared secrets for all "api" source_dir apps
│ ├── api.example.com # Secrets specific to api.example.com
│ └── api-staging.example.com
├── certs/ # Custom SSL certificates (optional)
│ └── api-example-com/
│ ├── server.crt
│ └── server.key
├── backups/ # Backup files (gitignored)
│ └── 2026-01-06_143022/ # Timestamped backup folder
│ ├── api-example-com-db.dump.xz
│ └── api-example-com-storage-1.tar.xz
├── api/ # Your API source code
│ ├── Dockerfile
│ ├── pre-deploy.sh # Runs before deploy (e.g., migrations)
│ └── post-deploy.sh # Runs after deploy (e.g., seed data)
└── web/ # Your web app source code
└── Dockerfile
{
"ssh_host": "dokku@<your-server-ip>",
"ssh_alias": "<ssh-alias>",
"global_domain": "example.com",
"letsencrypt_email": "admin@example.com",
"mysql_expose": {
"mysql-prod": "127.0.0.1:3306",
"mysql-staging": "127.0.0.1:3307",
"mysql-test": "127.0.0.1:3308",
"mysql-test-v2": "127.0.0.1:3309"
},
"api": {
"source_dir": "api",
"subtree_prefix": "services/api",
"branch": "main",
"builder": "dockerfile",
"postgres": true,
"letsencrypt": true,
"env_vars": {
"NODE_ENV": "production"
},
"deployments": {
"api.example.com": {
"tags": ["production", "api"],
"env_vars": {
"LOG_LEVEL": "warn"
}
},
"api-staging.example.com": {
"tags": ["staging", "api"],
"env_vars": {
"LOG_LEVEL": "debug"
}
}
}
}
}| Key | Description |
|---|---|
ssh_host |
Full SSH host for git push (e.g., dokku@1.2.3.4) |
ssh_alias |
SSH alias for commands (e.g., dokku if configured in ~/.ssh/config) |
global_domain |
Base domain used to synthesize app domains during import when only .dokku exists |
letsencrypt_email |
Global email used for Let's Encrypt certificate requests |
mysql_expose |
Map of MySQL service name to bind address for dokku mysql:expose (e.g., {"mysql-prod":"127.0.0.1:3306"}) |
| Key | Description |
|---|---|
source_dir |
Directory containing the source code and Dockerfile. Supports relative paths (api, ../sibling-repo) or absolute paths (/path/to/project) |
subtree_prefix |
Optional monorepo path to deploy via git subtree split --prefix (relative to repo root) |
branch |
Git branch to deploy (auto-detects if not set) |
builder |
Dokku builder type (for example dockerfile, herokuish, or pack) |
postgres |
Auto-create and link PostgreSQL database (true/false) |
letsencrypt |
Auto-provision Let's Encrypt SSL (true/false) |
env_vars |
Environment variables (set at runtime) |
build_args |
Docker build arguments (set at build time) |
storage_mounts |
Array of storage mounts - string "host:container" or object {"mount": "host:container", "backup": false} |
ports |
Array of port mappings ("http:80:3000") |
extra_domains |
Additional domains to add |
plugins |
Dokku plugins to install |
dokku_settings |
Generic Dokku plugin settings map (for example {"nginx":{"client-max-body-size":"100m"}}) |
Same options as parent level, plus:
| Key | Description |
|---|---|
tags |
Array of tags for filtering (["production", "api"]) |
Child settings override parent settings.
Use dokku_settings when you want config-driven dokku <plugin>:set behavior:
{
"dokku_settings": {
"nginx": {
"client-max-body-size": "100m"
}
}
}Notes:
- Parent and deployment
dokku_settingsare merged (deployment overrides parent per key). - Keys are applied as
dokku <plugin>:set <app> <key> <value>during deploy and--config-only. --syncnow comparesdokku_settingsas part of drift detection.- Import support currently captures
nginx.client-max-body-sizeonly; other plugin settings are still deploy-only unless you add custom import logic.
Use root-level mysql_expose to declare local-only binds for Dokku MySQL services:
{
"mysql_expose": {
"mysql-prod": "127.0.0.1:3306",
"mysql-staging": "127.0.0.1:3307"
}
}Behavior:
- Applied after deployment/config updates, in both full deploy and
--config-onlymode. - Skips the service if the desired bind is already active.
- If a different bind exists, unexposes all current bindings and re-exposes with the configured address.
- Accepts binds in the form
127.0.0.1:<port>or0.0.0.0:<port>where port is 1–65535. - If a listed service does not exist, deploy continues with a warning.
- In
--dry-runmode, prints the command that would run without executing it.
For local clients like DBeaver, prefer 127.0.0.1:<port> and connect through SSH tunnel:
ssh -N -L 13306:127.0.0.1:3306 -L 13307:127.0.0.1:3307 <ssh-alias>Then connect to 127.0.0.1 using local ports (13306, 13307, ...). Local port numbers are arbitrary — choose values that do not conflict with services already bound on your workstation.
Secrets are loaded hierarchically:
.env/_<source_dir>- Shared secrets for all apps with that source_dir.env/<domain>- Domain-specific secrets (overrides shared)
# .env/_api (shared by all api deployments)
DATABASE_PASSWORD=shared-secret
API_KEY=common-key
# .env/api.example.com (production-specific)
DATABASE_PASSWORD=production-secret# Deploy all apps
./deploy.sh
# Deploy specific app(s)
./deploy.sh api.example.com
./deploy.sh api.example.com www.example.com
# Deploy by tag
./deploy.sh --tag staging
./deploy.sh --tag api
./deploy.sh --tag staging --tag api # OR logic
# Skip production
./deploy.sh --no-prod
# Dry run (see what would happen)
./deploy.sh --dry-run
# Force deploy (even if no code changes)
./deploy.sh --force
# Update config only (no code deploy, just env vars + restart)
./deploy.sh --config-only api.example.com
# Skip confirmation prompts
./deploy.sh --yesCompare local config.json against live Dokku state without deploying:
# Check all selected deployments
./deploy.sh --sync
# Check only a subset
./deploy.sh --sync --tag staging
./deploy.sh --sync api.example.com
# Re-import live state before checking
./deploy.sh --sync --refresh-sync
# Clear cache and re-import
./deploy.sh --sync --reset-sync--sync behavior:
- Imports current Dokku app config to
.sync-cache/(no git clone, no env secret export) - Compares local vs remote by domain
- Reports:
✓ In sync✗ Missing on Dokku⚠ Driftwith per-field differences
Cache options:
--refresh-sync: refresh.sync-cache/config.jsonbefore comparing--reset-sync: clear the sync cache directory, then import fresh--sync-dir <dir>: use a custom cache directory
Exit codes:
0: all selected deployments are in sync1: drift/missing detected or sync check failed
Backup PostgreSQL databases and storage mounts to compressed .xz files:
# Backup all apps
./deploy.sh --backup
# Backup to custom directory
./deploy.sh --backup --backup-dir ~/dokku-backups
# Backup specific app
./deploy.sh --backup api.example.com
# Backup by tag
./deploy.sh --backup --tag production
# Dry run (see what would be backed up)
./deploy.sh --backup --dry-runThis creates timestamped backup folders:
./backups/2026-01-06_143022/
├── api-example-com-db.dump.xz # PostgreSQL dump (pg_dump custom format)
├── api-example-com-storage-1.tar.xz # Storage mount #1 contents
└── api-example-com-storage-2.tar.xz # Storage mount #2 contents
Backups are saved to ./backups/<timestamp>/ by default (gitignored).
Storage backup notes:
storage_mountssupports object entries like{"mount":"<host>:<container>","backup":false}.- Mounts marked
backup:falseare skipped in backup mode and explicitly listed in CLI output. - Mounts larger than
100MBare skipped by default and explicitly listed in CLI output. - Override threshold with
BACKUP_MAX_STORAGE_MB(0disables size-based skipping). - Backup mode prints a final consolidated "Manual rsync required" list across all apps.
- Use direct host-to-host
rsyncfor skipped large volumes during migrations.
Restore PostgreSQL databases and storage mounts from a backup:
# Restore to default server (from config.json ssh_alias)
./restore.sh backups/2026-01-31_085506
# Restore to specific server
SSH_HOST=co2 ./restore.sh backups/2026-01-31_085506
# Dry run (see what would be restored)
./restore.sh backups/2026-01-31_085506 --dry-runThe restore script will:
- Install postgres plugin if needed
- Create databases if they don't exist, or import into existing ones
- Extract storage archives to
/var/lib/dokku/data/storage/<app>/ - Fix permissions for Dokku
Workflow for server migration:
# 1. Deploy apps to new server (creates apps, empty DBs, storage mounts)
CONFIG_FILE=config-newserver.json ./deploy.sh
# 2. Restore data from backup
SSH_HOST=newserver ./restore.sh backups/2026-01-31_085506Create pre-deploy.sh or post-deploy.sh in your source directory:
# api/pre-deploy.sh
#!/bin/bash
echo "Running migrations..."
npm run db:migrate
# api/post-deploy.sh
#!/bin/bash
echo "Seeding database..."
npm run db:seedThe APP_NAME environment variable is available in hooks.
- Parse config - Reads
config.json, merges parent/child settings - Filter - Applies tag filters and deployment selection
- For each app:
- Sync with git origin
- Check if deployment needed (compare commits)
- Create Dokku app if needed
- Configure PostgreSQL if enabled
- Set domains
- Mount storage
- Set port mappings
- Load secrets from
.envfiles - Set env vars and build args
- Run pre-deploy hook
- Git push to Dokku
- Run post-deploy hook
- Health check
- Enable Let's Encrypt if configured
bash4.0+jq- JSON processor (brew install jqorapt install jq)gitsshaccess to your Dokku servercurl(for health checks)xz(for backup mode - usually pre-installed)
Add to ~/.ssh/config:
Host <ssh-alias>
HostName <your-server-ip>
User dokku
IdentityFile ~/.ssh/<your-key>
Then set "ssh_alias": "<ssh-alias>" in config.json.
MIT