Self-Hosted: Swag to NGINX Migration

This article covers moving an existing SWAG-based self-hosted install to the nginx + certbot deployment. If you're setting up a brand new server instead, follow Self Hosted: Standard Setup Guide from the start - this article only covers the migration path.

The config and certificate paths stay the same, /var/www/hudu2/config/nginx/site-confs/ and /var/www/hudu2/config/etc/letsencrypt/, since the new setup mounts into the same locations SWAG already used.

    Your existing certificate carries over as-is - no re-issuance needed. Renewal now uses webroot validation instead of SWAG's standalone validation, so nginx no longer has to stop to renew.

Stop the SWAG service

cd ~/hudu2
sudo docker compose stop letsencrypt
sudo docker compose rm -f letsencrypt

Create the webroot directory

This is where certbot drops its validation files and nginx serves them from:

sudo mkdir -p /var/www/hudu2/config/certbot-webroot

   Don't reuse SWAG's old /var/www/hudu2/config/www/ directory - it's SWAG-internal and isn't mounted into the new nginx service.

Edit docker-compose.yml

Remove the letsencrypt service block, and add these two service blocks in its place:

Expand to copy the nginx and certbot service blocks
  nginx:
    image: nginx:1.31.8-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
    On installs that have been through several past migrations, the renewal file can still reference the older container-internal path /etc/letsencrypt/... instead of /config/etc/letsencrypt. Check before moving on:
sudo grep -E 'archive_dir|cert|privkey|chain|fullchain' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf

If any of those lines point at /etc/letsencrypt/... rather than /config/etc/letsencrypt/..., add a second mount to the certbot service so certbot can resolve both paths:

      - /var/www/hudu2/config/etc/letsencrypt:/etc/letsencrypt

Leave the existing /config/etc/letsencrypt mount in place - this is additive, not a replacement.

    Nothing here needs customizing - domain is handled entirely in default.conf.

Replace default.conf

This file already exists from SWAG, so this is an edit, not a new file. Navigate to the folder:

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

Back up the file and confirm the certificate symlinks before you change anything:

sudo cp default.conf default.conf.swag-backup
sudo ls -l /var/www/hudu2/config/keys/

You should see cert.crt and cert.key as symlinks pointing into letsencrypt/. The config below keeps those paths, so there's no domain to substitute. If they're regular files instead of symlinks, don't use them - point ssl_certificate and ssl_certificate_key at /config/etc/letsencrypt/live/<your domain>/fullchain.pem and privkey.pem instead.

Replace the contents of default.conf with the following:

Expand to copy the content for the default.conf file
# Applies to all server blocks below
client_max_body_size 0;
server_tokens off;

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

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;

  ssl_certificate /config/keys/cert.crt;
  ssl_certificate_key /config/keys/cert.key;
  add_header Strict-Transport-Security "max-age=63072000";
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
  ssl_prefer_server_ciphers off;
  ssl_session_timeout 1d;
  ssl_session_cache shared:MozSSL:10m;

  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 {
    proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
    proxy_buffers 32 4k;
    proxy_connect_timeout 240;
    proxy_headers_hash_bucket_size 128;
    proxy_headers_hash_max_size 1024;
    proxy_http_version 1.1;
    proxy_read_timeout 240;
    proxy_redirect http:// $scheme://;
    proxy_send_timeout 240;
    proxy_cache_bypass $cookie_session;
    proxy_no_cache $cookie_session;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Early-Data $ssl_early_data;
    proxy_set_header Host $host;
    proxy_set_header Proxy "";
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Method $request_method;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Ssl on;
    proxy_set_header X-Forwarded-Uri $request_uri;
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://app:3000;
  }
}
include /config/nginx/proxy-confs/*.subdomain.conf;
proxy_cache_path cache/ keys_zone=auth_cache:10m;

   The first two lines apply to both server blocks. server_tokens off hides the nginx version number from response headers and error pages, and client_max_body_size 0 removes nginx's default 1 MB upload limit so large file and photo uploads to Hudu aren't rejected with a 413 error. The two OAuth discovery endpoints keep their own 1k limit.

Validate the config, then start nginx:

cd ~/hudu2
sudo docker compose run --rm --entrypoint nginx nginx -t
sudo docker compose up -d nginx

Visit your domain to confirm HTTPS loads with no certificate warning.

   If nginx won't start, check the symlink chain from inside the container:

sudo docker compose run --rm --entrypoint sh nginx -c 'readlink -f /config/keys/cert.crt && ls -l /config/keys/cert.crt'

A failure here means the /config mount is missing or partial.

Remove SWAG's leftover renewal hook scripts

Certbot automatically runs anything in renewal-hooks/pre/, renewal-hooks/post/, and renewal-hooks/deploy/ on every renewal. SWAG populates all three, and none of them work outside the SWAG image. List them first:

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

Then remove them:

sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/pre/10-nginx
sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/post/10-nginx
sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/deploy/10-default

   On older installs, SWAG's hooks can also show up as pre_hook, post_hook, or renew_hook lines written directly into the renewal file, rather than as scripts under renewal-hooks/. Deleting the scripts above won't remove these.

Check the renewal file:

sudo grep -E 'pre_hook|post_hook|renew_hook' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf

If any are present, remove those lines from the file - the renewal-hooks/ directories used earlier in this guide handle hooks going forward, so these inline entries are redundant leftovers. Empty renewal-hooks/ directories and non-symlink files under config/keys/ are expected on installs this old and are not, on their own, a sign of a problem.

   Removing deploy/10-default is safe - certbot updates the live/ symlinks itself on renewal. Confirm after your first real renewal:

sudo readlink -f /var/www/hudu2/config/keys/cert.crt
sudo openssl x509 -enddate -noout -in /var/www/hudu2/config/keys/cert.crt

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 cron job:

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

After your first real renewal, confirm the renewal file now records webroot:

sudo grep -E 'authenticator|webroot' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf

You should see authenticator = webroot and a webroot_path pointing at /var/www/certbot. Until then, keep the --webroot -w flags on the cron line.

Keeping nginx up to date

The nginx service uses the nginx:1.31.8-alpine tag, which always points to the latest patch release in the 1.31 line. Docker doesn't download new releases on its own, though - the container keeps running the image it was created from until you pull the updated image and recreate the container.

    Reloading nginx (nginx -s reload) or running docker compose restart does not update nginx. Both reuse the existing container and image, so the nginx version stays the same.

First, confirm the nginx service in docker-compose.yml uses this image tag:

    image: nginx:1.31.8-alpine

If it's pinned to a specific patch version instead, such as nginx:1.31.0-alpine, update it to the tag above so the next pull picks up the latest patch release.

Then choose one of the options below, depending on whether you want to update only nginx or every service in your compose file.

Option 1: Update nginx only

Use this when you only want to pick up a new nginx release and leave Hudu and the other services exactly as they are:

cd ~/hudu2
sudo docker compose pull nginx
sudo docker compose down nginx
sudo docker compose up -d nginx

Option 2: Update all services

Use this when you want to update everything at once. Running docker compose pull without a service name pulls the latest image for every service in docker-compose.yml, and docker compose up -d then recreates any service whose image changed. --remove-orphans also cleans up containers for services that are no longer in the file, such as the old SWAG container.

cd ~/hudu2
sudo docker compose pull
sudo docker compose down nginx
sudo docker compose up -d --remove-orphans
    If your Hudu app image uses a floating tag, option 2 can also update Hudu itself. Use option 1 if you only intend to update nginx.

   With either option, expect a brief outage between the down and up commands while the nginx container is recreated.

Troubleshooting

Renewal log shows "Hook reported error code 127"

A SWAG renewal hook script is still in place - it can't run outside the SWAG image. Renewal still completes; remove the three scripts above to clear the noise.

The dry run fails on the ACME challenge

Check the ^~ /.well-known/acme-challenge/ block is present and the webroot volume is mounted into both containers.

Config test fails on the ssl_ecdh_curve line

X25519MLKEM768 needs OpenSSL 3.5+. Remove it from the ssl_ecdh_curve line if your image's OpenSSL is older.

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 <your domain> 2>/dev/null | openssl x509 -enddate -noout
sudo openssl x509 -enddate -noout -in /var/www/hudu2/config/keys/cert.crt

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

nginx still reports the old version after reloading or restarting

Reloading and restarting both reuse the existing container, so the nginx version doesn't change. Pull the updated image and recreate the container - see Keeping nginx up to date.

Renewal file still points at /etc/letsencrypt instead of /config/etc/letsencrypt

Common on installs that have been through several past migrations. Add a second certbot volume mount for /etc/letsencrypt (see Edit docker-compose.yml) rather than editing the paths inside the renewal file directly.

Renewal fails with a "cd" or hook error pointing at a path that no longer exists

A pre_hook, post_hook, or renew_hook line is still present directly inside the renewal .conf file from a previous container generation. Remove those lines - see Remove SWAG's leftover renewal hook scripts.

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