Containers

How to Install Dokku on Ubuntu 26.04 / 24.04 (Self-Hosted PaaS)

Dokku gives you the Heroku workflow on a server you own. You git push, it builds a container from a buildpack or a Dockerfile, and nginx serves it on its own hostname. To install Dokku you run one bootstrap script pinned to a release tag, and it fits on a 1 GB VPS. Most older guides miss two changes, because older Ubuntu LTS releases are off the supported list and recent releases reject Procfile commands that old sample apps rely on.

Original content from computingforgeeks.com - post 2959

This guide covers the Dokku install on Ubuntu with the official bootstrap.sh, a first git push deploy, Let’s Encrypt HTTPS and the real errors we hit. CapRover and Coolify do the same job with a web dashboard if you prefer clicking. Every command below was run on Ubuntu 26.04.1 LTS with Dokku 0.38.34 in November 2026, and the install and deploy were repeated on 24.04.

Prepare the server

Dokku wants a clean server, because the installer takes over nginx and removes whatever sits in /etc/nginx/sites-enabled on the first run. Do not install it on a box that already serves sites you care about, since those vhosts are gone the moment the package configures. A fresh VM or one of the cheap VPS plans is the right starting point, with these in place before you begin.

  • A current Ubuntu LTS release or Debian, on amd64 or arm64
  • 1 GB RAM minimum for the Docker scheduler, or add swap
  • 20 GB of disk or more (sizing note below)
  • A user with sudo and your SSH public key already on the server
  • An A record for dokku.example.com pointing at the server’s public IP
  • A wildcard record *.dokku.example.com pointing at the same IP
  • Ports 22, 80 and 443 reachable from the internet if you want Let’s Encrypt

You need both records because a wildcard does not match the bare name. The A record is what git push connects to, and the wildcard is what gives every app its own hostname (node-demo.dokku.example.com). With a single A record instead, Dokku falls back to serving apps on random ports, which works but makes TLS awkward. Disk fills faster than people expect, and our lab VM went from 2.6 GB used to 8.1 GB after two small demo apps, because the buildpack builder image alone is 1.43 GB. Plan for 20 GB as a floor and more if you keep several apps with old releases around.

Bring the system up to date before the installer runs, then reboot if a new kernel landed. The installer restarts services as it goes, so it is cleaner to start from a fully patched box.

sudo apt update && sudo apt -y full-upgrade
[ -f /var/run/reboot-required ] && sudo reboot

On a fresh cloud image, cloud-init may already be running its own upgrade in the background. If apt complains about a lock, wait a minute for it to finish rather than killing it, because our first SSH session on the lab VM was cut off when that upgrade restarted sshd. Give it five minutes after first boot and you avoid the whole problem.

Install Dokku with the bootstrap script

The supported way to install Dokku is the bootstrap script from the official installation docs, pinned to a release with DOKKU_TAG. It installs Docker from Docker’s own repository, adds the Dokku apt repository, and pulls in nginx, herokuish, plugn and sshcommand, so you do not need to install Docker first. Pinning the tag matters because Dokku tags a release every few weeks, sometimes several in one week, and a guide that grabs “latest” gives two readers two different versions. Set the tag once, with the release page in the comment so you can check for a newer one.

export DOKKU_TAG=v0.38.34  # pin: tested release, newer tags at https://github.com/dokku/dokku/releases

Download the script for that tag. The URL redirects to the tagged file on GitHub, so you get exactly the script that shipped with the release.

wget -NP . https://dokku.com/install/${DOKKU_TAG}/bootstrap.sh

wget follows the redirect to the tagged copy on GitHub, and the 9 KB script lands in the current directory.

2026-11-03 16:36:06 (6.44 MB/s) - ‘./bootstrap.sh’ saved [9051/9051]

Read through it first if your policy says so, then run it as root. sudo drops most of your environment, which is why the tag is passed on the same line:

sudo DOKKU_TAG=${DOKKU_TAG} bash bootstrap.sh

The script pauses for 10 seconds to warn you about the nginx sites-enabled cleanup, then installs Docker and the Dokku packages. On our 2 vCPU, 4 GB VM it took 203 seconds end to end, most of it package downloads. The trimmed run below keeps the lines worth reading.

Preparing to install v0.38.34 from https://github.com/dokku/dokku.git...
--> Ensuring we have the proper dependencies
--> Note: Installing dokku for the first time will result in removal of
    files in the nginx 'sites-enabled' directory. Please manually
    restore any files that may be removed after the installation and
    web setup is complete.

    Installation will continue in 10 seconds.
--> Installing docker
--> Installing dokku
Setting up nginx (1.28.3-2ubuntu1.12) ...
Setting up plugn (0.17.1) ...
Setting up herokuish (0.11.24) ...
Setting up dokku (0.38.34) ...
--> Running post-install dependency installation

 ! Setup a user's ssh key for deployment by passing in the public ssh key as shown:

     echo 'CONTENTS_OF_ID_RSA_PUB_FILE' | dokku ssh-keys:add admin

Halfway through the log you will also see Error: keyfile '/root/.ssh/id_rsa.pub' not found. That line comes from the package trying to import root’s key automatically. It is harmless on a server where root has no key, and the next section adds a key the proper way.

Dokku bootstrap.sh install with DOKKU_TAG on Ubuntu 26.04 terminal output

Once the prompt comes back, check that each piece the installer promised is actually running. A failed Docker install can still leave the Dokku package half configured, so a quick check now saves a confusing first deploy.

Check what the installer set up

Dokku is a set of shell plugins around Docker and nginx, so a healthy install means all three answer. Print the versions and service states in one go.

dokku version
docker --version
nginx -v
id dokku
systemctl is-active docker nginx

You should see the tag you pinned, Docker from Docker’s repository rather than Ubuntu’s docker.io, and a dokku system user in the docker group.

dokku version 0.38.34
Docker version 29.9.2, build a82e528
nginx version: nginx/1.28.3 (Ubuntu)
uid=1001(dokku) gid=1001(dokku) groups=1001(dokku),4(adm),100(users),986(docker)
active
active

The dokku user is the one every deploy connects as. Its authorized_keys file is managed by Dokku, so never edit it by hand, because each key gets forced-command options that confine it to Dokku’s own commands. A key pasted in manually skips those options and gives whoever holds it a shell as a user in the docker group.

dokku version, Docker and nginx versions and active services on Ubuntu 26.04

No apps exist yet, which dokku apps:list confirms with “You haven’t deployed any applications yet”. The plugin list is worth a glance too, since it shows which features ship in core and which, like Let’s Encrypt, you add later.

Add your SSH key and set the global domain

Deploys are SSH connections as the dokku user, so Dokku needs the public key of every machine that will push. Run this from your workstation, which reads your local public key and registers it on the server under the name admin. Swap josphat@web01 for your own login and server. It relies on passwordless sudo, which the default user on cloud images has; if your sudo asks for a password, use the on-server variant below instead.

cat ~/.ssh/id_ed25519.pub | ssh josphat@web01 sudo dokku ssh-keys:add admin

If the key you log in with is already on the server, the docs’ shortcut works too, and you run cat ~/.ssh/authorized_keys | sudo dokku ssh-keys:add admin on the server itself. Either way, the rest of this section runs on the server, so log back in there and list the keys to confirm the fingerprint matches yours.

sudo dokku ssh-keys:list

Each key is stored with a forced command, which is what stops it opening a shell, and the options in SSHCOMMAND_ALLOWED_KEYS block agent and port forwarding on top of that.

SHA256:XR0pL4RD1SqvIjAWIulTl0hcvrhwjsbzYILWmQLadnQ NAME="admin" SSHCOMMAND_ALLOWED_KEYS="no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding"

Next, tell Dokku which domain your wildcard record covers. Every new app gets <app>.<global domain> as its hostname from now on, so set this before creating apps rather than renaming vhosts later.

dokku domains:set-global dokku.example.com

Without real DNS yet, you can use the server’s IP with .sslip.io appended (for example 10.0.1.50.sslip.io), which resolves any subdomain back to that IP. It is fine for testing, but use your own domain for anything that needs a certificate you control. Now create the app that the push will target.

dokku apps:create node-demo

Dokku writes the nginx vhost straight away. The “No web listeners” warning only means nothing is running behind it yet.

-----> Creating node-demo...
-----> Creating new app virtual host file...
 !     No web listeners specified for node-demo
-----> Configuring node-demo.dokku.example.com...(using built-in template)
-----> Creating http nginx.conf
       Reloading nginx

The key, the domain and the new app all show up in one terminal session on the server.

dokku ssh-keys:add, domains:set-global and apps:create output on Ubuntu

Pick app names carefully at this point, because they have to be lowercase. A name with dots is also treated as a full hostname rather than a subdomain, which the troubleshooting section shows.

Open the firewall

UFW ships inactive on the Ubuntu cloud image we used, so nothing blocks traffic until you turn it on. Allow SSH before enabling it, or you lock yourself out of the session you’re typing in and need the provider’s console to get back.

sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enable
sudo ufw status

Three ports are all Dokku needs, since nginx proxies every app on 80 and 443 and containers are not published on the host.

Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80,443/tcp                 ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)
80,443/tcp (v6)            ALLOW       Anywhere (v6)

If your provider has its own cloud firewall, open the same ports there as well, because Let’s Encrypt validation fails the moment port 80 is closed anywhere on the path.

Deploy an app with git push

A Dokku git push deploy works like Heroku. You add the server as a git remote and push a branch, and the push itself runs the build. We used Heroku’s Node.js sample app because it has a Procfile and no database, so the first deploy tests Dokku and nothing else. On your workstation, clone it.

git clone https://github.com/heroku/node-js-getting-started.git node-demo
cd node-demo

Add the Dokku server as a remote. The part after the colon is the app name you created, and the user is always dokku, never your own login. With your own account the push fails, because git looks for a node-demo repository in your home directory.

git remote add dokku [email protected]:node-demo

Push the main branch to deploy it. Dokku deploys whichever branch you push first and records it as the deploy branch.

git push dokku main

Dokku spots package.json, builds with the Node.js buildpack through herokuish, reads the Procfile, starts the container and runs health checks before switching nginx over. The first push took 167 seconds here, mostly pulling the builder image and npm packages, and later pushes reuse the cache. Watch for the “Application deployed” line at the end.

remote: -----> Building node-demo from herokuish
remote:        -----> Node.js app detected
remote:        Downloading and installing node 24.22.2...
remote:        added 342 packages in 7s
remote:        -----> Discovering process types
remote:        Procfile declares types -> web
remote: -----> Releasing node-demo...
remote: -----> Deploying node-demo via the docker-local scheduler...
remote: -----> Executing 2 healthchecks
remote:        All checks successful (web.1)
remote: =====> Start of node-demo container output (4ea6d561cc93 web.1)
remote:        Listening on 5000
remote: =====> End of node-demo container output (4ea6d561cc93 web.1)
remote: =====> Application deployed:
remote:        http://node-demo.dokku.example.com
To dokku.example.com:node-demo
 * [new branch]      main -> main

The two health checks are Dokku’s defaults, so the container must stay up for 10 seconds and listen on the port it was given. An app that crashes on boot fails here and the old release keeps serving, which is the zero-downtime behaviour you get for free. For anything real, add proper checks in app.json, as the warning in the full log suggests.

git push dokku main deploying a Node.js app with herokuish buildpack

Check the app from the command line before opening a browser. nginx answers for the app hostname and the X-Powered-By header proves the request reached Express inside the container.

curl -sI http://node-demo.dokku.example.com/

A 200 with both headers means DNS, nginx and the container are all wired up. A 502 here would point at the container, while a connection refused points at DNS or the firewall.

HTTP/1.1 200 OK
Server: nginx
Date: Tue, 03 Nov 2026 16:47:56 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 9109
Connection: keep-alive
Vary: Accept-Encoding
X-Powered-By: Express
ETag: W/"2395-oZcdSvgjyp38rt0Uful7JbgvM3s"

The same URL in a browser shows the sample app’s landing page. The page still says Heroku because it’s Heroku’s own sample, served unchanged from the container Dokku just built.

Node.js sample app deployed on Dokku open in a browser

How Dokku builds the image decides how much disk each app costs. A repo with a Dockerfile is built with Docker instead of herokuish, and on the same Node.js code the difference was large.

BuilderPicked whenImage size (same app)
herokuish (buildpacks)No Dockerfile in the repo1.84 GB, of which 1.43 GB is the shared herokuish base
DockerfileA Dockerfile is committed257 MB on a Node alpine base image

The herokuish base is shared between buildpack apps, so each extra app adds roughly 0.4 GB on top of it, but the base still has to fit on the disk. Buildpacks are the fastest way to get an unmodified app running, while on a small VPS the Dockerfile route keeps the footprint down. The Dockerfile builder also parses your Procfile more strictly, which is where the next problem comes from. Once a few apps are live, ctop or the Arcane Docker UI show what each container is using, and a private Docker registry lets you deploy prebuilt images with dokku git:from-image instead of building on the server.

Enable HTTPS with Let’s Encrypt

Dokku letsencrypt support comes from the official dokku-letsencrypt plugin, not the core install, so the plugin list from earlier has no letsencrypt entry. It runs the lego ACME client in a container, answers the HTTP-01 challenge through nginx while the app keeps serving, and installs the certificate on the app. Install it as root on the server.

sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git

The install pulls the lego image (126 MB here) and enables the plugin. Confirm it registered.

dokku plugin:list | grep letsencrypt

The plugin shows as enabled, with its own version number separate from Dokku’s. Update it on its own schedule with sudo dokku plugin:update letsencrypt, since Dokku upgrades leave third-party plugins alone.

  letsencrypt          0.25.2 enabled    Automated installation of let's encrypt TLS certificates

Let’s Encrypt refuses to register an account without a contact address, and it rejects placeholder domains such as example.com outright. Set a real mailbox you own once, globally, so every app inherits it.

dokku letsencrypt:set --global email [email protected]

Request the certificate for the app. The app must already be deployed and reachable from the internet on its hostname, because Let’s Encrypt fetches a challenge file from it.

dokku letsencrypt:enable node-demo

Certificates last 90 days, so add the daily renewal job right away instead of trusting yourself to remember. The job only renews certificates inside the 30-day grace period, so running it daily costs nothing.

sudo dokku letsencrypt:cron-job --add

The job goes into the dokku user’s crontab and runs auto-renew once a day.

-----> Added cron job to dokku's crontab.

The lab run below used the placeholder [email protected], which is exactly what produced the invalidContact error in the troubleshooting section, so do not copy that address. The cron job was also added without sudo in that run, and it landed in the same crontab, but the plugin docs use sudo and so does the command above.

dokku letsencrypt plugin install, global email and cron job on Ubuntu 26.04

We could not test every part of this. The lab server sits on a private LAN with no public IP, so Let’s Encrypt’s validators cannot reach it and no certificate was issued. The enable step above was run, and its real failures are in the troubleshooting section. On a public server the plugin’s README shows the run ending with Certificate retrieved successfully, followed by nginx being reconfigured for SSL. Check it from outside with curl -I https://node-demo.dokku.example.com, and run dokku letsencrypt:list to see each app’s expiry and renewal date.

While you are still getting DNS and firewalls right, point the app at the staging CA with dokku letsencrypt:set node-demo server staging. Failed production attempts eat into Let’s Encrypt’s rate limits, while staging has far higher limits, and dokku letsencrypt:set node-demo server with no value switches back. Staging was returning HTTP 503 during our run, so if it fails with that status, the problem is on their end and not your config.

Repeat the install on the previous LTS

The bootstrap script supports the last three Ubuntu LTS releases, and the steps do not change between them, because Docker’s install script picks the right repository for the release codename on its own. We ran the same pinned bootstrap, key setup, global domain and git push on a server running the previous LTS with 2 GB of RAM, and the app answered with HTTP 200.

Dokku install and git push smoke test on Ubuntu 24.04 returning HTTP 200

One difference showed up, and it was not Ubuntu’s. That push ran non-interactively from the server itself, and git stopped with Host key verification failed because the host key was not in known_hosts yet. From a normal terminal you get a yes/no prompt instead, so this only bites in CI, where you add the server with ssh-keyscan before the first push.

Troubleshoot Dokku deploy errors

The first four errors below came out of this lab with the exact message you’ll search for, and the last covers the failure a server without public HTTP access should expect. Two of them are newer behaviour, because Dokku 0.38.31 stopped accepting bare shell operators in Procfile commands and started validating app names in every app-scoped command, so sample apps and scripts written for older releases can break after an upgrade.

Unable to parse command: `&&` is not a valid word

This one hits Procfiles that chain commands, such as running migrations before the web process. We reproduced it by deploying the sample app with a Dockerfile and this Procfile line, pushed from the server itself to a second app called procfile-demo.

web: echo "booting" && node index.js

The push built the image, then the deploy step refused to start the container and git rejected the push. The old release, if there is one, keeps running.

remote: -----> Deploying web (count=1)
remote:  !     Unable to parse command "echo \"booting\" && node index.js": 1:16: `&&` is not a valid word
remote: parallel: This job failed:
remote: /var/lib/dokku/plugins/available/scheduler-docker-local/bin/scheduler-deploy-process-container procfile-demo dockerfile dokku/procfile-demo:latest latest web 1 1
 ! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '127.0.0.1:procfile-demo'

Dokku now splits Procfile commands with shell quoting rules and runs them directly, with no shell in between, so a chained && or a pipe is just a word it cannot execute. The Dockerfile build hit it, and other non-herokuish builders should be expected to as well. We did not confirm the chained command running under the buildpack builder, which starts processes through herokuish’s own launcher and is expected to handle it, so test a buildpack app before relying on that.

Dokku Unable to parse command && is not a valid word Procfile error

The fix lives in the app repo, not on the server. Open the Procfile:

vim Procfile

Wrap the chain in an explicit shell. Dokku then runs sh as the process, and sh handles the operator.

web: sh -c "echo booting && node index.js"

Commit and push again. The container output now shows both commands running in order.

remote: =====> Start of procfile-demo container output (c08a57014db5 web.1)
remote:        booting
remote:        Listening on 5000
remote: =====> End of procfile-demo container output (c08a57014db5 web.1)
...
remote: =====> Application deployed:
remote:        http://procfile-demo.dokku.example.com:5000

Better still, move one-off steps like migrations into a release process in the Procfile, which Dokku runs once per deploy before the web containers start, rather than on every container restart.

Application deployed on :5000 instead of port 80

Look at the URL in that last output: :5000. Our Dockerfile had EXPOSE 5000, and Dokku turns an exposed port into a matching http:5000:5000 mapping, so nginx listened on 5000 and the firewall kept it closed. Map port 80 to the container port explicitly:

dokku ports:set procfile-demo http:80:5000

Dokku rewrites the nginx config and reloads it. Check the result with the ports report:

dokku ports:report procfile-demo

The report shows the new mapping next to the detected one, and the app answered on port 80 straight after. Your setting wins over the detected value on every later deploy.

       Ports map:                     http:80:5000
       Ports map detected:            http:5000:5000

The fixed deploy and the port change, captured in order:

Dokku Procfile wrapped in sh -c deploying and ports:set http:80:5000

A buildpack app never hits this, because herokuish apps get the default http:80:5000 mapping. It’s the EXPOSE line in a Dockerfile that changes it, so check dokku ports:report <app> after the first Dockerfile deploy.

App name must begin with lowercase alphanumeric character

App names are lowercase letters, digits, dots and hyphens, and since the same release every app-scoped command checks the name, not only apps:create. A script that passes Node-Demo to config:show or ps:report now fails with this message:

 !     App name must begin with lowercase alphanumeric character, and may only contain lowercase alphanumerics, dots, and hyphens

Lowercase the name everywhere you use it, including CI variables. Avoid dots too: dokku apps:create node.app succeeds, but Dokku configured the vhost as node.app itself rather than node.app.dokku.example.com, so it never matched the wildcard record.

invalidContact: contact email has forbidden domain

Copying a sample email like [email protected] into letsencrypt:set gets the account registration rejected before any challenge runs:

       2026/11/03 16:58:37 [INFO] acme: Registering account for [email protected]
       2026/11/03 16:58:37 [WARN] retry: acme: error: 400 :: urn:ietf:params:acme:error:invalidContact :: Error validating contact(s) :: contact email has forbidden domain "example.com"
       2026/11/03 16:58:37 Could not complete registration
-----> Certificate retrieval failed!
-----> Disabling ACME proxy for node-demo...
 !     Failed to setup letsencrypt

The full run shows the plugin generating an account key first, so the failure happens at registration, before any HTTP-01 challenge.

dokku letsencrypt:enable invalidContact forbidden domain example.com error

Set an address on a domain you own and enable again. Clearing the email entirely doesn’t help either, since the plugin then stops with ERROR: Cannot request a certificate without an e-mail address! before contacting Let’s Encrypt at all.

Certificate retrieval failed on a server behind NAT

HTTP-01 needs Let’s Encrypt to fetch http://<app hostname>/.well-known/acme-challenge/... from the public internet. A home lab or LAN VM like ours cannot pass it, and neither can a server where the wildcard record points somewhere else or a cloud firewall blocks port 80. Check each link in that chain before retrying:

  • dig +short node-demo.dokku.example.com returns the server’s public IP
  • curl -I http://node-demo.dokku.example.com from another network returns the app
  • Port 80 is open in UFW and in the provider’s firewall

If the server genuinely can’t be reached on port 80, the plugin also supports DNS-01 through lego’s DNS providers (set dns-provider with letsencrypt:set, plus the provider’s credentials as dns-provider-* properties, as the plugin README shows), which proves control through a TXT record instead. For a server on the open internet with the three checks passing, plain dokku letsencrypt:enable is all it takes, and that’s the setup we’d run: pinned bootstrap, one wildcard record, Dockerfile builds for anything that has to stay small.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Ubuntu Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Install Latest Node.js & NPM on Ubuntu / Debian Debian Install Latest Node.js & NPM on Ubuntu / Debian

Leave a Comment

Press ESC to close