Skip to content

TutorialsAI & LLM

How to self-host Dify with Docker Compose and HTTPS

Deploy Dify with Docker Compose on Ubuntu or Debian: replace default secrets, keep its Nginx on localhost behind Caddy HTTPS, connect Ollama and plan upgrades.

  • Intermediate
  • 45 min read
  • Updated

Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13

On this page
  1. Prerequisites
  2. Step 1 — Clone a Dify release
  3. Step 2 — Create .env and replace the default secrets
  4. Step 3 — Start the stack
  5. Step 4 — Publish Dify over HTTPS with Caddy
  6. Step 5 — Create the admin account
  7. Step 6 — Connect model providers, including Ollama
  8. Back up and restore
  9. Update Dify
  10. Troubleshooting
  11. Port 80 or 443 is already in use
  12. fatal: Remote branch null not found in upstream origin
  13. 502 Bad Gateway after a restart
  14. The page loads forever or the console shows CORS errors
  15. An HTTP Request node cannot reach an internal address
  16. Next steps

Dify is an open-source platform for building applications on top of large language models: chatbots, agents, knowledge bases with retrieval (RAG) and visual workflows, each with its own API. The official way to self-host it is Docker Compose from the project's repository. The default stack runs the API, background workers, the web front end, a plugin daemon, a code sandbox, an agent backend, PostgreSQL, Redis, the Weaviate vector database and an Nginx gateway.

This guide clones a Dify release into /opt/dify, replaces every default password and key before the first start, keeps the bundled Nginx on a localhost port behind Caddy for HTTPS, protects the first-run admin page with an init password and connects Ollama. It finishes with the backup and upgrade routine.

Prerequisites

ResourceMinimum (official)Suggested starting point
CPU2 cores4 vCPU
RAM4 GiB8 GB
DiskNot published40 GB or more for images, uploads and vector data

The suggested column is a conservative starting point, not a benchmark. Confirm your Compose version before you continue:

Bash
docker compose version

Step 1 — Clone a Dify release

Dify's documentation clones the latest release tag, not the development branch. The command asks GitHub's API for the newest tag and therefore needs curl and jq; python3 is used in Step 2 to generate two tokens.

Bash
sudo apt update
sudo apt install git curl jq openssl python3
sudo mkdir -p /opt/dify
sudo chown $USER:$USER /opt/dify
git clone --branch "$(curl -s https://api.github.com/repos/langgenius/dify/releases/latest | jq -r .tag_name)" https://github.com/langgenius/dify.git /opt/dify
cd /opt/dify
git describe --tags

git describe --tags prints the release you checked out, for example 1.17.1. If the clone fails with Remote branch null not found, the API call did not return a tag; pick the current version from the releases page and run git clone --branch 1.17.1 https://github.com/langgenius/dify.git /opt/dify with that tag instead.

Step 2 — Create .env and replace the default secrets

Everything you configure lives in /opt/dify/docker/.env, created from .env.example. Optional, provider-specific settings have their own templates under docker/envs/; values in .env take precedence over them.

Bash
cd /opt/dify/docker
cp .env.example .env
chmod 600 .env

The example file ships with publicly known defaults, such as difyai123456 for PostgreSQL and Redis and fixed keys for the sandbox, Weaviate and the plugin daemon. Replace them with random values. Several pairs must match, which is why the command reuses the same shell variable: REDIS_PASSWORD also appears inside CELERY_BROKER_URL, SANDBOX_API_KEY must equal CODE_EXECUTION_API_KEY, and the Weaviate key appears twice.

Bash
DB_PW=$(openssl rand -hex 24)
REDIS_PW=$(openssl rand -hex 24)
SANDBOX_KEY=$(openssl rand -hex 24)
WEAVIATE_KEY=$(openssl rand -hex 24)
sed -i \
  -e "s|^SECRET_KEY=.*|SECRET_KEY=$(openssl rand -base64 42)|" \
  -e "s|^INIT_PASSWORD=.*|INIT_PASSWORD=$(openssl rand -hex 12)|" \
  -e "s|^DB_PASSWORD=.*|DB_PASSWORD=${DB_PW}|" \
  -e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PW}|" \
  -e "s|^CELERY_BROKER_URL=.*|CELERY_BROKER_URL=redis://:${REDIS_PW}@redis:6379/1|" \
  -e "s|^CODE_EXECUTION_API_KEY=.*|CODE_EXECUTION_API_KEY=${SANDBOX_KEY}|" \
  -e "s|^SANDBOX_API_KEY=.*|SANDBOX_API_KEY=${SANDBOX_KEY}|" \
  -e "s|^WEAVIATE_API_KEY=.*|WEAVIATE_API_KEY=${WEAVIATE_KEY}|" \
  -e "s|^WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=.*|WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=${WEAVIATE_KEY}|" \
  -e "s|^PLUGIN_DAEMON_KEY=.*|PLUGIN_DAEMON_KEY=$(openssl rand -hex 32)|" \
  -e "s|^PLUGIN_DIFY_INNER_API_KEY=.*|PLUGIN_DIFY_INNER_API_KEY=$(openssl rand -hex 32)|" \
  -e "s|^DIFY_AGENT_API_TOKEN=.*|DIFY_AGENT_API_TOKEN=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')|" \
  -e "s|^DIFY_AGENT_SERVER_SECRET_KEY=.*|DIFY_AGENT_SERVER_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')|" \
  .env

SECRET_KEY signs sessions, tokens and file URLs; the documentation generates it with openssl rand -base64 42. INIT_PASSWORD (at most 30 characters) protects the /install page until you create the admin account. The two DIFY_AGENT_* values replace development defaults for the agent backend, generated the way the example file suggests.

Next, tell Dify its public address and move every published port to localhost. The Compose file maps Nginx as EXPOSE_NGINX_PORT:80 and EXPOSE_NGINX_SSL_PORT:443, and the plugin daemon's remote-debugging port as EXPOSE_PLUGIN_DEBUGGING_PORT. Putting 127.0.0.1: in front of the host port keeps them off the internet and frees ports 80 and 443 for Caddy.

Bash
DIFY_URL=https://dify.example.com
sed -i \
  -e "s|^CONSOLE_API_URL=.*|CONSOLE_API_URL=${DIFY_URL}|" \
  -e "s|^CONSOLE_WEB_URL=.*|CONSOLE_WEB_URL=${DIFY_URL}|" \
  -e "s|^SERVICE_API_URL=.*|SERVICE_API_URL=${DIFY_URL}|" \
  -e "s|^APP_API_URL=.*|APP_API_URL=${DIFY_URL}|" \
  -e "s|^APP_WEB_URL=.*|APP_WEB_URL=${DIFY_URL}|" \
  -e "s|^FILES_URL=.*|FILES_URL=${DIFY_URL}|" \
  -e "s|^TRIGGER_URL=.*|TRIGGER_URL=${DIFY_URL}|" \
  -e "s|^ENDPOINT_URL_TEMPLATE=.*|ENDPOINT_URL_TEMPLATE=${DIFY_URL}/e/{hook_id}|" \
  -e "s|^NEXT_PUBLIC_SOCKET_URL=.*|NEXT_PUBLIC_SOCKET_URL=wss://dify.example.com|" \
  -e "s|^EXPOSE_NGINX_PORT=.*|EXPOSE_NGINX_PORT=127.0.0.1:8080|" \
  -e "s|^EXPOSE_NGINX_SSL_PORT=.*|EXPOSE_NGINX_SSL_PORT=127.0.0.1:8443|" \
  -e "s|^EXPOSE_PLUGIN_DEBUGGING_PORT=.*|EXPOSE_PLUGIN_DEBUGGING_PORT=127.0.0.1:5003|" \
  .env
grep -E '^(INIT_PASSWORD|DB_PASSWORD|CONSOLE_WEB_URL|EXPOSE_NGINX_PORT|EXPOSE_NGINX_SSL_PORT)=' .env

The URL settings matter behind a proxy: CONSOLE_API_URL also decides whether Dify issues HTTPS-only cookies, FILES_URL builds file preview links, TRIGGER_URL builds webhook callbacks and NEXT_PUBLIC_SOCKET_URL is the WebSocket address for real-time collaboration on the workflow canvas. Note the INIT_PASSWORD value from the grep output; you need it in Step 5. Sign-up stays closed because ALLOW_REGISTER defaults to false, so new members join only by invitation.

Step 3 — Start the stack

Bash
docker compose up -d
docker compose ps

The first start pulls a dozen images and takes a few minutes. In docker compose ps, the services should show Up or healthy; init_permissions is a one-time task and showing Exited is expected. The nginx line should list 127.0.0.1:8080->80/tcp and 127.0.0.1:8443->443/tcp, and no port may be bound to 0.0.0.0. Check that the gateway answers locally:

Bash
curl -I http://127.0.0.1:8080/install

Any HTTP status line (usually 200) means Nginx, the web front end and the API are talking to each other.

Step 4 — Publish Dify over HTTPS with Caddy

Add a site block to /etc/caddy/Caddyfile that forwards to the bundled Nginx:

Caddyfile
dify.example.com {
    reverse_proxy 127.0.0.1:8080
}

Reload Caddy and allow only SSH and web traffic through ufw:

Bash
sudo systemctl reload caddy
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
curl -I https://dify.example.com/install

Caddy passes the original Host header, forwards WebSocket upgrades for the collaboration channel automatically and flushes streamed responses immediately, so chat answers appear token by token. Dify's own HTTPS support in Nginx (NGINX_HTTPS_ENABLED) stays off, because Caddy terminates TLS. Nginx with Certbot (guide) or Traefik work as alternatives; with Nginx, forward the Upgrade and Connection headers on /socket.io/.

Step 5 — Create the admin account

Open https://dify.example.com/install. Dify asks for the init password from Step 2, then for the admin's email address, name and password. Use a long, unique password. After that you land in the studio, and the init password has no further effect.

Invite colleagues from the workspace's member settings instead of opening sign-up. Only the workspace owner and admins can manage model providers.

Dify emails account invitations, password resets and login codes once mail is configured in .env: set MAIL_TYPE=smtp, MAIL_DEFAULT_SEND_FROM, SMTP_SERVER=smtp.example.com, SMTP_PORT=587, SMTP_USERNAME, SMTP_PASSWORD, SMTP_USE_TLS=true and SMTP_OPPORTUNISTIC_TLS=true for STARTTLS, then run docker compose up -d.

Step 6 — Connect model providers, including Ollama

In Dify 1.x every model provider is a plugin. Open Integrations, then Model Provider, install the providers you need from the Marketplace and click Setup to enter API keys. Dify checks each key before it saves it. The plugin daemon downloads plugins from marketplace.dify.ai and verifies their signatures by default (FORCE_VERIFYING_SIGNATURE=true), so keep outbound HTTPS open.

For Ollama on the same server, three things are needed. First, Ollama must listen beyond 127.0.0.1, which the Ollama FAQ does with OLLAMA_HOST:

Bash
sudo mkdir -p /etc/systemd/system/ollama.service.d
sudo tee /etc/systemd/system/ollama.service.d/override.conf <<'EOF'
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
EOF
sudo systemctl daemon-reload
sudo systemctl restart ollama

Second, ufw must let the Dify network reach the host. The Compose project is named after the docker folder, so the network is docker_default. Third, Dify's Compose file defines no host.docker.internal alias, so use the Docker bridge address of the host, usually 172.17.0.1:

Bash
docker network inspect docker_default | grep Subnet
sudo ufw allow from 172.19.0.0/16 to any port 11434 proto tcp
ip -4 addr show docker0
curl http://172.17.0.1:11434/api/version

Replace 172.19.0.0/16 with the subnet printed by the first command, and 172.17.0.1 with the inet address of docker0 if it differs. This rule only protects Ollama while ufw is active with its default deny policy, so confirm that sudo ufw status verbose shows Status: active and deny (incoming), and that nc -vz your-server-ip 11434 from another machine fails. The last command should return Ollama's version as JSON. Then install the Ollama plugin in Dify, add a model with the exact name shown by ollama list and set the base URL to http://172.17.0.1:11434. For bigger models, a GPU server makes a large difference.

Back up and restore

Dify keeps its state in /opt/dify/docker/volumes (PostgreSQL data, uploaded files in app/storage, Weaviate vectors, Redis data, plugin storage) and its configuration in .env. Take a logical database dump while the stack runs, then stop it briefly to archive the files consistently:

Bash
sudo mkdir -p /opt/backups
sudo chown $USER:$USER /opt/backups
cd /opt/dify/docker
docker compose exec -T db_postgres pg_dumpall -U postgres | gzip > /opt/backups/dify-db-$(date +%F).sql.gz
docker compose stop
sudo tar czf /opt/backups/dify-files-$(date +%F).tar.gz -C /opt/dify/docker volumes .env
docker compose start

pg_dumpall captures both the dify database and the plugin daemon's dify_plugin database. To restore on a new server, clone the same release tag as in Step 1, unpack the file archive into the docker folder and start the stack:

Bash
cd /opt/dify/docker
sudo tar xzf /opt/backups/dify-files-2026-10-09.tar.gz -C /opt/dify/docker
docker compose up -d

The SQL dump is your second line of defence: load it into an empty PostgreSQL with gunzip -c dify-db-2026-10-09.sql.gz | docker compose exec -T db_postgres psql -U postgres if the data directory itself is damaged. Copy both archives off the server; they contain your secrets.

Update Dify

Dify's upgrade notes can differ between releases, so read the release notes of every version between yours and the target first.

The usual Docker Compose procedure from the release notes, with a full backup copy and the environment sync script added:

Bash
cd /opt/dify/docker
docker compose down
sudo cp -a /opt/dify /opt/backups/dify-$(date +%F)
git fetch --tags
git checkout 1.17.1
chmod +x dify-env-sync.sh
./dify-env-sync.sh
docker compose pull
docker compose up -d

Replace 1.17.1 with the target tag. cp -a keeps ownership, so the copy can be restored as is. dify-env-sync.sh adds new variables from .env.example to your .env without overwriting your values, and saves the old file in env-backup/ first. Database migrations run automatically when the API starts; watch them with docker compose logs -f api.

Troubleshooting

Port 80 or 443 is already in use

Caddy owns ports 80 and 443 on the host. If docker compose up fails with address already in use, the EXPOSE_NGINX_PORT or EXPOSE_NGINX_SSL_PORT lines in .env were not changed. Fix them as in Step 2 and run docker compose up -d again.

fatal: Remote branch null not found in upstream origin

The GitHub API call returned no tag, often because of rate limiting. Clone with an explicit tag from the releases page, as shown in Step 1.

502 Bad Gateway after a restart

Check docker compose ps: every service must be Up or healthy. Dify's documentation explains that the bundled Nginx can keep forwarding to outdated container addresses after containers restart. Restart the gateway with docker compose restart nginx; after a server reboot, run docker compose down and docker compose up -d.

The page loads forever or the console shows CORS errors

The URL variables in .env do not match the address in your browser. Correct CONSOLE_API_URL, CONSOLE_WEB_URL, SERVICE_API_URL, APP_API_URL, APP_WEB_URL and FILES_URL, then run docker compose down and docker compose up -d.

An HTTP Request node cannot reach an internal address

This is by design: Dify routes outbound requests from HTTP nodes through its ssrf_proxy, which blocks internal and private ranges. Only if you trust every workflow author, add ACL rules to docker/volumes/ssrf_proxy/squid.conf as the documentation describes and restart the proxy container.

Next steps

Frequently asked questions

How much memory does Dify need?

Dify's documentation asks for at least 2 CPU cores and 4 GiB of RAM. The default stack starts more than a dozen containers, including PostgreSQL, Redis and Weaviate, so 8 GB leaves room to work. Local models need their own memory on top.

How do I reset a forgotten Dify account password?

Run docker compose exec api flask reset-password in /opt/dify/docker. The command asks for the account email and the new password, so it also works when no mail server is configured.

Why does the install page ask for a password?

This guide sets INIT_PASSWORD in .env. Dify then requires that password on the /install page before anyone can create the admin account, which protects a fresh server that is already reachable over HTTPS. After setup the value has no further effect.

Can I change the database password later by editing .env?

Not by editing .env alone. PostgreSQL stores the password in its data directory when the database is first created, so a new DB_PASSWORD no longer matches. Set strong values before the first start, or change the password inside PostgreSQL as well.

Can Dify use models running in Ollama?

Yes. Install the Ollama provider plugin from the Dify Marketplace, make Ollama reachable from Docker networks and use the Docker bridge address, usually http://172.17.0.1:11434, as the base URL.

Sources

Generate Password

Please confirm