Standard Self-Hosted Setup Guide

This guide is for setting up Hudu with a free LetsEncrypt SSL certificate that will renew automatically for you. If you are looking to use a custom SSL certificate, you may want to read this article for alternative options: Getting Started with Hudu Self-Hosted.

This standard, LetsEncrypt method, is Hudu's recommended setup method for self-hosting.

   This guide uses the official nginx image with certbot handling certificate issuance and renewal. If you have an existing self-hosted install running SWAG, see Migrating an existing SWAG setup to NGINX rather than following the fresh-install steps below in place. Config and certificate file locations below match SWAG's existing layout (/var/www/hudu2/config/...), so anyone migrating keeps the same paths they're already used to.

Prerequisites

In order to self-host Hudu, you will need:

Server with at least 4GB memory and at least 1 CPU (as long as there is no contention), with Ubuntu (22.04 LTS, or newer) as the OS.
SMTP server for sending outgoing mail.
An A record pointed to the Public IP of the instance. Usually something like docs.yourdomain.com or hudu.yourdomain.com.
Ports 443 and 80 opened externally (required for LetsEncrypt).

Guides

Instructions

Test that ports 443 and 80 are open for your publicly accessible A record. Enter your URL into this tool. If both ports aren't showing as open, fix that first.

SSH into the server you are wishing to host Hudu on.

Install Docker CE on the server. Setup instructions can be found here. Do not use SNAP to install Docker - this will cause issues later on.

Create the directory structure. On Ubuntu:

mkdir -p ~/hudu2
mkdir -p /var/www/hudu2/config/nginx/site-confs
mkdir -p /var/www/hudu2/config/etc/letsencrypt
mkdir -p /var/www/hudu2/config/certbot-webroot
cd ~/hudu2

   Every docker compose command in this guide needs to be run from ~/hudu2, since that's where docker-compose.yml lives. If you've navigated elsewhere while editing config files, cd ~/hudu2 first.

Place a file named docker-compose.yml in the hudu2 directory.

Expand to copy the content for the docker-compose.yml file
volumes:
  postgres_data: {}
  app_data: {}
  redis_data: {}

services:
  db:
    image: 'postgres:18.6'
    volumes:
      - postgres_data:/var/lib/postgresql
    env_file:
      - '.env'
    logging:
      driver: "json-file"
      options:
        max-file: "5"
        max-size: "10m"
    restart: unless-stopped

  redis:
    image: 'redis:latest'
    command: redis-server
    volumes:
      - redis_data:/var/lib/redis/data
    restart: unless-stopped

  app:
    image: hududocker/hudu:latest
    env_file:
      - '.env'
    volumes:
      - app_data:/var/www/hudu2/public/uploads/
      - app_data:/var/www/hudu2/uploads/
      - app_data:/var/lib/app/data
    depends_on:
      - db
      - redis
    logging:
      driver: "json-file"
      options:
        max-file: "5"
        max-size: "100m"
    restart: unless-stopped

  worker:
    depends_on:
      - db
      - redis
    image: hududocker/hudu:latest
    command: bundle exec sidekiq -C config/sidekiq.yml
    volumes:
      - app_data:/var/www/hudu2/public/uploads/
      - app_data:/var/www/hudu2/uploads/
      - '.:/app'
    env_file:
      - '.env'
    logging:
      driver: "json-file"
      options:
        max-file: "5"
        max-size: "100m"
    restart: unless-stopped

  nginx:
    image: nginx:1.31-alpine
    container_name: nginx
    restart: always
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/www/hudu2/config:/config:ro
      - /var/www/hudu2/config/nginx/site-confs:/etc/nginx/conf.d:ro
      - /var/www/hudu2/config/certbot-webroot:/var/www/certbot:ro
      - /var/www/hudu2/public:/var/www/hudu2/public:ro
    depends_on:
      - app

  certbot:
    image: certbot/certbot:v5.7.0
    container_name: certbot
    volumes:
      - /var/www/hudu2/config/etc/letsencrypt:/config/etc/letsencrypt
      - /var/www/hudu2/config/certbot-webroot:/var/www/certbot

    Nothing in this file needs to be customized for a standard install - domain and email are set later, in the certificate command and the nginx config, not here. Certbot has no restart policy - it's only ever run on-demand with docker compose run, never left running in the background.

    Visit Upgrade Postgres in a Production Environment for additional information on upgrading an existing docker-compose.yml file rather than starting fresh.

Place the bootstrap nginx config. Before a certificate exists, nginx only needs to serve plain HTTP so certbot can complete the challenge. Navigate to the folder:

cd /var/www/hudu2/config/nginx/site-confs/

Create a new file named default.conf there with this content:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 200 'Hudu is starting up - waiting on SSL certificate issuance.';
        add_header Content-Type text/plain;
    }
}

    Nothing in this file needs to be customized - it's identical for every install. The domain only comes into play in the certificate step and the final config further down.

Generate your .env file by running this command:

cd ~/hudu2 && bash <(curl -fsSL https://raw.githubusercontent.com/Hudu-Technologies-Inc/self-hosting/refs/heads/main/hudu-env-wizard.sh)

Follow the prompts to provide your subdomain and root domain. Choose your preferred storage method (y for S3, n for local storage). If you select S3, you will be prompted to enter your S3 configuration details. These settings can always be edited later.

    Never edit the generated encryption keys in this file. These keys are used to decrypt passwords and two-factor authentication data. Changing them will result in permanent data loss or errors when creating, viewing, or editing passwords and when using two-factor authentication.

    It is critical that you store an exact copy of the .env file in a secure location. Your encryption and secure keys are located in this file, and you can lose access to passwords and more if this file is lost.

Start the stack with the bootstrap config in place:

sudo docker compose up -d

Wait for the command to finish, then visit http://docs.yourdomain.com (plain HTTP, no s) and confirm you see the "Hudu is starting up" placeholder text. This confirms nginx is reachable and ready to serve the ACME challenge.

Request your certificate. This uses certbot's webroot method - nginx stays up the whole time, serving the validation file certbot drops into the webroot directory. --config-dir needs to match the mount path above, since it's not certbot's default:

sudo docker compose run --rm certbot certonly --config-dir /config/etc/letsencrypt --webroot -w /var/www/certbot -d docs.yourdomain.com --email you@yourdomain.com --agree-tos --no-eff-email
    Replace docs.yourdomain.com with your actual domain and you@yourdomain.com with a real email address you can receive renewal notices at. Everything else in the command stays as-is.

A successful run ends with a message confirming the certificate was saved under /config/etc/letsencrypt/live/docs.yourdomain.com/.

Replace the nginx config. Navigate to the site-confs folder:

cd /var/www/hudu2/config/nginx/site-confs/
    Before you copy the file below, find and replace every instance of docs.yourdomain.com with your actual domain - it appears twice, in the ssl_certificate and ssl_certificate_key lines marked # CHANGE. Then paste the whole file in to completely replace the existing default.conf.
Expand to copy the content for the default.conf file
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name _;

  location ^~ /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }

  location / {
    return 301 https://$host$request_uri;
  }
}

server {
  server_name _;
  listen 443 ssl;
  listen [::]:443 ssl;
  http2 on;
  root   /var/www/hudu2/public;
  index  index.html;

  # CHANGE: replace docs.yourdomain.com with your actual domain on these 2 lines
  ssl_certificate /config/etc/letsencrypt/live/docs.yourdomain.com/fullchain.pem;
  ssl_certificate_key /config/etc/letsencrypt/live/docs.yourdomain.com/privkey.pem;

  location = /.well-known/oauth-authorization-server {
    limit_except GET { deny all; }
    client_max_body_size 1k;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://app:3000;
  }

  location = /.well-known/oauth-protected-resource {
    limit_except GET { deny all; }
    client_max_body_size 1k;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://app:3000;
  }

  location /mcp {
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
    gzip off;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://app:3000;
  }

  location ~ /\. {
    deny all;
  }

  location ~* ^.+\.(rb|log)$ {
    deny all;
  }

  location /cable {
    proxy_pass http://app:3000/cable;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 240s;
    proxy_send_timeout 240s;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass_request_headers on;
    proxy_buffering off;
    proxy_redirect off;
    break;
  }

  location / {
    try_files $uri @rails;
  }

  location @rails {
    client_body_buffer_size 128k;
    proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
    send_timeout 5m;
    proxy_read_timeout 240;
    proxy_send_timeout 240;
    proxy_connect_timeout 240;
    proxy_set_header Early-Data $ssl_early_data;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Ssl off;
    proxy_redirect http:// $scheme://;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_cache_bypass $cookie_session;
    proxy_no_cache $cookie_session;
    proxy_buffers 32 4k;
    proxy_headers_hash_bucket_size 128;
    proxy_headers_hash_max_size 1024;
    proxy_pass http://app:3000;
  }
}

include /config/nginx/proxy-confs/*.subdomain.conf;
proxy_cache_path cache/ keys_zone=auth_cache:10m;

Validate the config, then reload nginx to pick up the new config - no restart or downtime required. Navigate back to the ~/hudu2 directory and run:

sudo docker compose exec nginx nginx -t
sudo docker compose exec nginx nginx -s reload

Your Hudu should now be up and running! Visit your domain to confirm. If you see a Hudu sign-up screen, it's successful. Head to the Hudu HQ Billing Portal for a license key.

hudu_startup_screen.png

If you don't see this screen, contact support, and please provide logs:

sudo docker compose logs

Please paste these into something like Pastebin.com.

Check for renewal hook scripts

Certbot automatically runs anything in renewal-hooks/pre/, renewal-hooks/post/, and renewal-hooks/deploy/ on every renewal. On a fresh install these are normally empty - certbot doesn't populate them itself - but it's worth confirming before you rely on unattended renewal, especially if any config was copied over from an existing box:

sudo ls -la /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/{pre,post,deploy}/

   If any of these turn up non-empty, remove anything you don't recognize - a leftover hook script (for example, one from a SWAG-based box) can fail on every renewal with "Hook reported error code 127" if it can't run outside its original image.

Confirm renewal works. This dry run validates against Let's Encrypt's staging environment, so it won't affect your real certificate or count against rate limits. --config-dir must match the mount path above:

sudo docker compose run --rm certbot renew --config-dir /config/etc/letsencrypt --webroot -w /var/www/certbot --dry-run

    nginx stays running the whole time - no downtime. If it fails, check that the ^~ /.well-known/acme-challenge/ block is in place and the webroot volume is mounted into both containers.

Set up automatic renewal. Certbot certificates are valid for 90 days. Add a host-level cron job so renewal happens without any manual steps:

crontab -e

Add this line:

0 3 * * * cd ~/hudu2 && docker compose run --rm certbot renew --config-dir /config/etc/letsencrypt --webroot -w /var/www/certbot >> /var/log/hudu-certbot-renew.log 2>&1; docker compose exec -T nginx nginx -s reload >> /var/log/hudu-certbot-renew.log 2>&1

   Runs daily but only acts within 30 days of expiry. The -T flag is required - cron has no TTY and the reload fails silently without it.

Test the reload line as cron will run it:

sudo docker compose exec -T nginx nginx -s reload && echo reloaded

Having Issues?

Have you checked that your ports are open? Use this tool.

Are you pasting in the .env or docker-compose.yml from a Windows computer? It could be formatted incorrectly.

Additional Environment Options

Do I need to configure any SSL-related environment variables?

No. The standard setup includes built-in SSL via nginx and certbot, so variables like DISABLE_SSL and BEHIND_TLS_PROXY are not required.

How do I increase the upload size limit?

Use MAX_FILE_SIZE to set the upload limit in MB. For example, MAX_FILE_SIZE=250 sets the limit to 250 MB. The default is 100 MB.

How do I use S3-compatible storage?

Use the S3 variables when USE_LOCAL_FILESYSTEM is not set to true. Configure S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION, and optionally S3_ENDPOINT for providers like Wasabi or MinIO.

When should I use S3_FORCE_PATH_STYLE?

Use S3_FORCE_PATH_STYLE=true for MinIO or other S3-compatible providers that require path-style bucket access.

When should I use S3_REMOVE_ENCRYPTION_HEADER?

Use S3_REMOVE_ENCRYPTION_HEADER=true if your S3-compatible provider rejects AWS server-side encryption headers.

When should I use S3_TURN_OFF_VERIFY_PEER?

Use S3_TURN_OFF_VERIFY_PEER=true only for trusted endpoints where TLS verification must be skipped. This is not recommended for public or untrusted endpoints.

Can I change rate limiting?

Use RATE_LIMIT_REQUESTS to adjust the per-IP request limit and LOGIN_THROTTLE_PERIOD_SECONDS to adjust the login throttle window.

 

Troubleshooting

Stuck on the "Hudu is starting up" placeholder page

This means the HTTPS config hasn't been applied yet, or nginx hasn't been reloaded since you swapped it in. Confirm the certificate exists:

ls /var/www/hudu2/config/etc/letsencrypt/live/

If your domain shows up there, navigate to the config folder and confirm default.conf matches the HTTPS version above with the correct ssl_certificate paths:

cd /var/www/hudu2/config/nginx/site-confs/

Then reload:

sudo docker compose exec nginx nginx -s reload
Connection refused when visiting my domain

Check whether nginx is actually staying up, rather than restart-looping:

sudo docker compose ps

If the nginx container shows a very recent "Up" time relative to when it was created, it's crashing and restarting. Check why:

sudo docker compose logs nginx --tail=50

A common cause is default.conf missing entirely from /var/www/hudu2/config/nginx/site-confs/, or a syntax error in the HTTPS version referencing a certificate path that doesn't exist yet. Test the config before reloading:

sudo docker compose exec nginx nginx -t
Certbot fails the webroot challenge

This is almost always ports 80/443 not being reachable from the public internet, or the A record not resolving to this server yet. Re-run the port scanner tool linked above, and confirm your domain resolves to this server's public IP with:

dig +short docs.yourdomain.com
Site still serves the old certificate after renewal

Compare what's served to what's on disk:

echo | openssl s_client -connect localhost:443 -servername docs.yourdomain.com 2>/dev/null | openssl x509 -enddate -noout
sudo openssl x509 -enddate -noout -in /var/www/hudu2/config/etc/letsencrypt/live/docs.yourdomain.com/fullchain.pem

If the served date is older, the reload step didn't run - test it manually with the -T flag.

    Visit Getting Started with Hudu Self-Hosted for additional information on choosing between this guide, a custom SSL cert, or running with no SSL.

Was this article helpful?
0 out of 0 found this helpful