Self-Hosting Dify with Docker and Caddy: Setup and Security Hardening Design

A technical note detailing self-hosting procedures for Dify Community Edition using Docker and Caddy, bypassing UFW and Docker network conflicts, automatic SSL renewal, and security hardening measures.

The demand for self-hosting Dify, an LLM orchestration tool, is rapidly growing to bypass functional limitations of the public cloud version (such as constraints on the number of applications that can be created and knowledge pipelines). However, operating in a local environment introduces availability challenges such as machine sleep, dynamic IPs, and firewall traversal. Establishing a practical production environment can be achieved by deploying Dify using Docker on a 24/7 Linux VPS, along with automated HTTPS and security hardening via Caddy.

This note explains the infrastructure design and concrete implementation procedures based on Dify v1.17.x and Caddy v2.11.x to securely expose the application to the outside world via a reverse proxy while ensuring host machine security.


1. System Architecture Design

The network and container deployment topology in this environment is as follows:

[ External Client / Webhook Source ]
                 │
                 │  https://dify.<domain> (Port 443)
                 ▼
┌────────────────────────────────── VPS Host ──────────────────────────────────┐
│                                                                              │
│  Caddy Reverse Proxy (Auto ACME SSL Management, Ports 80 / 443)              │
│     │                                                                        │
│     │ reverse_proxy (via local loopback)                                     │
│     ▼                                                                        │
│  127.0.0.1:8080                                                              │
│     │                                                                        │
│     │ Docker Port Mapping                                                    │
│     ▼                                                                        │
│  dify-nginx (Container Port :80)                                             │
│     ├─ web / api / worker / worker_beat                                      │
│     ├─ db_postgres / redis / weaviate                                        │
│     └─ plugin_daemon (127.0.0.1:5003)                                        │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘
Exposed External Ports: 80/tcp, 443/tcp, 443/udp (Caddy), SSH (Custom Port)

As a paramount security design decision, the containers comprising Dify (Nginx, PostgreSQL, Redis, Plugin Daemon, etc.) do not bind to the host’s global network interface (0.0.0.0) at all, but bind exclusively to the loopback address (127.0.0.1). The Caddy reverse proxy receives all incoming external traffic, terminates SSL/TLS, and forwards it internally.


2. Provisioning Host Infrastructure

2-1. Updating System Packages and Installing Dependencies

Update packages on the host OS (assuming Ubuntu 22.04 LTS / 24.04 LTS) and install the required utility tools.

sudo apt update &amp;&amp; sudo apt upgrade -y
sudo apt install -y git curl jq acl

2-2. Installing and Verifying Docker Engine

If Docker Engine is not installed, install it using the official script. Verify that the Docker Compose version is v2.24.0 or higher (required for supporting the !override syntax described later).

docker --version 2&gt;/dev/null || curl -fsSL https://get.docker.com | sudo sh
docker compose version
systemctl is-enabled docker

2-3. Creating a Dedicated System User and Setting Permissions

To ensure security, avoid running containers as the root user and create a dedicated dify user for operation.

sudo adduser dify
sudo usermod -aG docker dify
sudo usermod -aG sudo dify

2-4. Allocating Swap Space (4GB)

Dify runs multiple microservices (over 10 containers) simultaneously in the backend, consuming 3–5 GB of memory even when idle. To prevent kernel OOM (Out of Memory) killer invocation due to insufficient memory, explicitly allocate 4 GB of swap space.

sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
free -h

3. Deploying Dify Orchestration

The following tasks should be executed after switching to the newly created dify user.

3-1. Cloning the Repository and Configuring Environment Encryption

Dynamically retrieve the latest release version via the GitHub API and clone the repository.

cd ~
git clone --branch "$(curl -s https://api.github.com/repos/langgenius/dify/releases/latest | jq -r .tag_name)" https://github.com/langgenius/dify.git
cd ~/dify/docker
cp .env.example .env
chmod 600 .env

Replace the initial passwords and encryption keys in the .env file with high-entropy random strings. Running with default values (such as difyai123456) is strictly prohibited as it significantly increases the risk of unauthorized external access.

# Generate key for session token encryption
sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$(openssl rand -base64 42)|" .env

# Generate passwords for PostgreSQL and Redis
DBP=$(openssl rand -hex 24)
RP=$(openssl rand -hex 24)

sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$DBP|" .env
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$RP|" .env

# Apply Redis password to Celery Broker URL
sed -i "s|^CELERY_BROKER_URL=.*|CELERY_BROKER_URL=redis://:$RP@redis:6379/1|" .env

3-2. Restricting Port Binding to Loopback

By default in Docker, performing port mappings opens ports across all host interfaces (0.0.0.0), which bypasses host-side firewalls like UFW. To prevent this, explicitly add a 127.0.0.1: prefix to the exposed port configurations in .env.

sed -i 's|^NGINX_PORT=.*|NGINX_PORT=80|' .env
sed -i 's|^NGINX_SSL_PORT=.*|NGINX_SSL_PORT=443|' .env

sed -i 's|^EXPOSE_NGINX_PORT=.*|EXPOSE_NGINX_PORT=127.0.0.1:8080|' .env
sed -i 's|^EXPOSE_NGINX_SSL_PORT=.*|EXPOSE_NGINX_SSL_PORT=127.0.0.1:8443|' .env
sed -i 's|^EXPOSE_PLUGIN_DEBUGGING_PORT=.*|EXPOSE_PLUGIN_DEBUGGING_PORT=5003|' .env

3-3. Plugin Daemon Binding Restriction (Docker Compose Override)

The plugin_daemon service is configured by default to bind to 0.0.0.0:5003. To safely override this, create docker-compose.override.yaml and restrict it to the loopback address.

# ~/dify/docker/docker-compose.override.yaml
services:
  plugin_daemon:
    ports: !override
      - "127.0.0.1:5003:5003"

3-4. Starting Containers and Initial Health Check

Once configured, start the containers in the background.

docker compose up -d

After startup, verify via local loopback whether the API server responds normally.

sleep 30
curl -s http://127.0.0.1:8080/console/api/setup

If operating correctly, it returns a JSON response of {“step”:“not_started”,“setup_at”:null}.


4. Setting Up HTTPS Reverse Proxy with Caddy

Use Caddy to automatically acquire SSL/TLS certificates from Let’s Encrypt and terminate HTTPS communication.

4-1. Installing Caddy

Add the official repository and install Caddy.

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update &amp;&amp; sudo apt install -y caddy

4-2. Configuring Caddyfile

Edit /etc/caddy/Caddyfile to route traffic from the domain to internal 127.0.0.1:8080.

# /etc/caddy/Caddyfile
dify.example.com {
    reverse_proxy 127.0.0.1:8080
}

Note: Replace dify.example.com with your domain configured in DNS to point to the VPS public IP.

Check the configuration file syntax, and if there are no issues, reload the Caddy service.

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

5. Troubleshooting

The following section details representative friction points frequently encountered during the self-hosted environment setup and operational phases, along with their resolutions.

5-1. UFW (Firewall) Bypass Issue by Docker

  • Symptom: External direct access to http://<VPS_IP>:8080 remains possible despite configuring sudo ufw deny 8080/tcp on the host.
  • Cause: Docker directly manipulates host iptables (Netfilter) rules for container routing, causing packets to reach containers with higher priority than UFW filtering rules.
  • Resolution: Explicitly specify the IP address in EXPOSE_NGINX_PORT within .env, such as 127.0.0.1:8080. This ensures containers bind only to the loopback interface, physically blocking direct external access.

5-2. Database Authentication Error After Initial Launch

  • Symptom: After changing DB_PASSWORD in .env post-deployment, the api container enters a crash loop outputting FATAL: password authentication failed for user “postgres”.
  • Cause: The PostgreSQL container initializes the database using the password from .env only on initial startup (when the volume is empty). Changing the value in .env after startup does not update the password stored within the database inside the container, causing a mismatch.
  • Resolution: To change the password, execute an ALTER USER statement directly inside the PostgreSQL container to update the database-side password, or destroy the data and reinitialize (note: all data will be lost).

5-3. Connection Refusal for Asynchronous Tasks (Celery)

  • Symptom: Document upload or indexing tasks to Knowledge (RAG) remain stuck in the ‘in progress’ state.
  • Cause: When changing the Redis password (REDIS_PASSWORD), the connection password contained in CELERY_BROKER_URL was not synchronized, preventing asynchronous workers from fetching jobs from the queue.
  • Resolution: Ensure that CELERY_BROKER_URL in .env matches the format redis://:<REDIS_PASSWORD>@redis:6379/1 and exactly aligns with the current REDIS_PASSWORD, then restart the containers.

5-4. “502 Bad Gateway” Caching by Nginx Container

  • Symptom: During api container restart or immediately after an update, 502 Bad Gateway continues to display in the browser despite Caddy functioning properly.

  • Cause: The Dify frontend Nginx container may be caching an old IP address of the upstream api container, or name resolution failed before api finished starting up, halting routing.

  • Resolution: Confirm that the api container is fully operational, then restart only the Nginx container independently.

    docker compose restart nginx
    

6. Operational Verification

After deployment is complete, execute the following verification commands to confirm that the system is operating securely as designed.

6-1. Checking Container Operational Status

Verify that all containers are in the Up (or Up (healthy)) status.

$ docker compose ps
NAME                IMAGE                               COMMAND                  SERVICE             CREATED             STATUS                    PORTS
dify-api-1          langgenius/dify-api:0.15.3          "/bin/sh -c './entry…"   api                 2 hours ago         Up 2 hours (healthy)      
dify-db-1           postgres:15-alpine                  "docker-entrypoint.s…"   db                  2 hours ago         Up 2 hours (healthy)      5432/tcp
dify-nginx-1        nginx:1.25-alpine                   "/docker-entrypoint.…"   nginx               2 hours ago         Up 2 hours                127.0.0.1:8080-&gt;80/tcp, 127.0.0.1:8443-&gt;443/tcp
dify-plugin_daemon  langgenius/dify-plugin-daemon:0.15  "/entrypoint.sh"         plugin_daemon       2 hours ago         Up 2 hours (healthy)      127.0.0.1:5003-&gt;5003/tcp
dify-redis-1        redis:7.2-alpine                    "docker-entrypoint.s…"   redis               2 hours ago         Up 2 hours (healthy)      6379/tcp
dify-sandbox-1      langgenius/dify-sandbox:0.5.2       "/main"                  sandbox             2 hours ago         Up 2 hours (healthy)      
dify-web-1          langgenius/dify-web:0.15.3          "/bin/sh -c './entry…"   web                 2 hours ago         Up 2 hours (healthy)      
dify-weaviate-1     semitechnologies/weaviate:1.19.0    "/bin/weaviate --hos…"   weaviate            2 hours ago         Up 2 hours (healthy)      
dify-worker-1       langgenius/dify-api:0.15.3          "/bin/sh -c './entry…"   worker              2 hours ago         Up 2 hours (healthy)

6-2. Auditing Host Listening Ports

Verify that externally exposed ports are restricted as designed to SSH (e.g., 2022) and Caddy (80, 443) only.

$ sudo ss -tulnp | grep -vE '127\.0\.0\.|::1|%lo'
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
tcp   LISTEN 0      4096         0.0.0.0:80         0.0.0.0:*     users:(("caddy",pid=1024,fd=6))
tcp   LISTEN 0      4096         0.0.0.0:443        0.0.0.0:*     users:(("caddy",pid=1024,fd=7))
tcp   LISTEN 0      128          0.0.0.0:2022       0.0.0.0:*     users:(("sshd",pid=821,fd=3))
udp   UNCONN 0      0            0.0.0.0:443        0.0.0.0:*     users:(("caddy",pid=1024,fd=8))

Verify that ports such as 8080, 5003, and 5432 are not exposed to 0.0.0.0.

6-3. End-to-End HTTPS Connection Verification

Test from an external client machine whether HTTPS communication via Caddy terminates normally and reaches the Dify setup endpoint.

$ curl -I https://dify.example.com/console/api/setup
HTTP/2 200 
alt-svc: h3=":443"; ma=2592000
content-type: application/json; charset=utf-8
date: Sat, 03 Oct 2026 14:22:15 GMT
server: Caddy
server: nginx

If server: Caddy and server: nginx appear sequentially in the response headers and a status code of 200 is returned, the reverse proxy chain is functioning correctly.


7. Lifecycle Dynamics and Container Updates

Temporary service downtime occurs when containers are recreated due to Dify version upgrades or configuration changes. When the connection to the backend (127.0.0.1:8080) is disconnected, Caddy automatically returns 502 Bad Gateway to the client.

To partially achieve rolling updates (near zero-downtime switching), pre-pulling new images prior to stopping containers is an effective approach to minimize the time required for container recreation and startup.

cd ~/dify/docker
git pull origin main
docker compose pull
docker compose up -d --remove-orphans

This eliminates downtime caused by image download times and compresses service interruption to a few seconds to tens of seconds required for container restarts.


Operational Notes

  • 💡 Backup Automation: The dify-db-1 (PostgreSQL) data volume and .env file hold all system configurations and user data. Registering a cron job to periodically run pg_dump and offload backups to secure off-host storage is strongly recommended.
  • 🛠️ Log Rotation: In Docker default configurations, container standard output logs expand indefinitely, consuming disk space. Configure maximum size limits for the json-file driver in /etc/docker/daemon.json (e.g., max-size: “10m”).
  • ⚠️ API Key Lifecycle: API keys for external LLMs (OpenAI, Anthropic, etc.) integrated within Dify are encrypted using SECRET_KEY in .env and stored in the database. Changing this key breaks all existing external integrations; therefore, changing SECRET_KEY after going into production is strictly prohibited.
Built with Hugo
Theme Stack designed by Jimmy
Privacy Policy Disclaimer Contact