log in
consulting hosting industries the daily tools about contact

Let's Encrypt in Docker: Ditch the Cronjob, Use a Sidecar

Cronjob-based cert renewal inside Docker is a disaster waiting to happen. Here's the sidecar pattern that actually holds in production.

The cronjob approach to Let's Encrypt renewal inside Docker works fine — until it doesn't, and then it fails silently at 2 a.m. thirty days before your cert expires. I've been burned by this twice, once badly enough that a client's e-commerce checkout was throwing browser warnings on a Monday morning. This is the config I landed on after that, and it's been holding across a dozen production stacks for two years.

The Actual Problem

Let's Encrypt certs expire every 90 days. That's intentional — short-lived certs reduce the blast radius of a compromised key. The Certbot documentation will tell you to run a cronjob twice a day. That works great on a bare VM where cron is a first-class citizen. Inside Docker, it's a different story.

The issues stack up fast:

  • Docker containers don't have cron running by default, so you're either installing it in your app container (gross) or running a separate container just for cron (closer, but still awkward).
  • When Certbot renews, it needs to reload Nginx. Sending a signal across container boundaries is not pretty.
  • If the renewal container exits with a non-zero code — cert already valid, ACME rate limit hit, DNS hiccup — you need to know about it. Cron silently swallows failures unless you've got mail configured, which nobody does in 2024.
  • Volume mount timing. If your Nginx container starts before the cert volume is populated, you get a crash loop on first deploy.

The pattern I use now: a dedicated sidecar container running certbot renew on a loop, sharing a volume with Nginx, and triggering a reload via a shared socket or a simple polling trick. No cron. No signals across namespaces. No mystery.

The Stack

Here's the docker-compose.yml that I actually deploy. I'm using Nginx as the reverse proxy in front of a Laravel app container, but the TLS layer is the same regardless of what's behind it.

services:
  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot_webroot:/var/www/certbot:ro
      - certbot_certs:/etc/letsencrypt:ro
      - nginx_run:/var/run/nginx
    depends_on:
      - app
    command: >
      sh -c "while :; do nginx -s reload 2>/dev/null; sleep 6h; done &
             nginx -g 'daemon off;'"

  certbot:
    image: certbot/certbot:latest
    restart: unless-stopped
    volumes:
      - certbot_webroot:/var/www/certbot
      - certbot_certs:/etc/letsencrypt
    entrypoint: >
      sh -c "trap exit TERM;
             while :; do
               certbot renew --webroot -w /var/www/certbot --quiet;
               sleep 12h & wait $${!};
             done"

  app:
    build: .
    restart: unless-stopped
    environment:
      - APP_ENV=production

volumes:
  certbot_webroot:
  certbot_certs:
  nginx_run:

A few things to unpack here.

The Nginx command override runs a background loop that calls nginx -s reload every 6 hours, then starts Nginx in the foreground as PID 1. That reload is what picks up newly issued certs. It's blunt, but it works — Nginx does a graceful reload, in-flight requests are not dropped, and the process is self-contained within that container.

The Certbot sidecar runs its own loop: attempt renewal, sleep 12 hours, repeat. The trap exit TERM means it shuts down cleanly when Docker sends SIGTERM. The sleep 12h & wait $${!} pattern (the double dollar sign is Compose escaping) lets the sleep be interrupted by the trap — without it, docker stop sits there for 10 seconds waiting for the sleep to finish.

The stagger matters. Nginx reloads every 6 hours. Certbot checks every 12. Certbot only actually talks to Let's Encrypt when the cert is within 30 days of expiry — the rest of the time certbot renew is a no-op. So in practice you're making zero ACME requests for 60 days, then a handful of idempotent requests, then the next Nginx reload picks up the new cert. Clean.

The Nginx Config That Doesn't Break on First Boot

This tripped me up the first time. If you reference your cert files in the Nginx config before they exist, Nginx won't start. You get a crash loop, Docker restarts it, same result.

The fix is to issue the cert before the stack is fully up, or to have a bootstrapping config. I do the latter. I keep two config files:

nginx/conf.d/default.conf — the real config, used after certs exist:

server {
    listen 80;
    server_name example.com;

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

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

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    location / {
        proxy_pass         http://app:8000;
        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 $scheme;
    }
}

For first deploy, I run a one-shot Certbot command before bringing the full stack up:

# Bring up just nginx with HTTP only (no SSL block yet)
docker compose up -d nginx

# Issue the cert
docker compose run --rm certbot certonly \
  --webroot \
  -w /var/www/certbot \
  -d example.com \
  --email ops@example.com \
  --agree-tos \
  --no-eff-email

# Now bring up the full stack with SSL config in place
docker compose up -d

I script this into a ./bin/bootstrap shell script in every project. First deploy runs bootstrap. Subsequent deploys just run docker compose pull && docker compose up -d. The sidecar handles renewals from there on out.

Gotchas That Will Bite You

Rate limits. Let's Encrypt allows 5 duplicate cert requests per week per domain. If your bootstrap script is in a loop because something's misconfigured, you'll hit the limit and be locked out for a week. Always test against the staging environment first: add --staging to your Certbot command. The staging CA issues certs your browser won't trust, but the ACME flow is identical.

The webroot path has to match in both containers. Certbot writes the challenge file to /var/www/certbot/.well-known/acme-challenge/. Nginx has to serve that path from the same volume mount. I've seen this break when someone changes the Nginx root in conf.d without updating the Certbot webroot flag. The ACME challenge fails, Certbot logs a warning, the loop continues, and 30 days later the cert expires.

certbot renew exit codes. Exit code 0 means renewal succeeded or wasn't needed. Exit code 1 means something failed. The quiet flag suppresses output but not exit codes. If you want alerting, wrap the certbot call and ship the exit code somewhere:

certbot renew --webroot -w /var/www/certbot --quiet
if [ $? -ne 0 ]; then
  curl -s -X POST https://hooks.slack.com/... \
    -H 'Content-type: application/json' \
    --data '{"text":"certbot renewal failed on example.com"}'
fi

I do this for client stacks where I'm on the hook for uptime. For my own stuff, I rely on UptimeRobot to catch the eventual expiry and deal with it then. Your call.

DNS propagation on new domains. If you're setting up a new domain and the DNS hasn't fully propagated, Certbot will fail the challenge. Not a Docker problem, but I've wasted time debugging the container setup when the real issue was a TTL that hadn't expired.

Alpine-based Nginx images don't have bash. The command override in the compose file uses sh, not bash. Don't copy examples from the internet that use bash syntax — array variables, [[, etc. — in an Alpine context. It'll fail in ways that look like Docker weirdness.

When I'd Reach for This

This pattern is the right default for any Docker stack I run on a VPS or dedicated host where I control the infrastructure. That covers the majority of what NWOS ships: Laravel apps on DigitalOcean or Hetzner, self-hosted tooling for clients, internal dashboards.

I'd skip it in a few situations:

  • Kubernetes. cert-manager is the answer there. It has ACME support, automatic secret rotation, and it handles the Nginx reload problem properly through annotations.
  • Behind a load balancer that does TLS termination. If you're on AWS and using an ALB or CloudFront, let Amazon manage the cert with ACM. There's no reason to run Certbot inside your containers.
  • High-traffic public APIs. Not because this pattern breaks, but because you probably have more sophisticated infrastructure at that point and should be using a dedicated ingress controller.

For a single-server Docker Compose stack serving a business application — healthcare portal, biotech data platform, e-commerce back-end — this is the setup I come back to every time. It's simple enough to understand in a 3 a.m. incident, and it's been running without intervention long enough that I've stopped thinking about it, which is exactly what infrastructure should do.

Closing

The cronjob felt like the obvious answer because that's how we did it on bare metal for years. Docker made it awkward, and the sidecar loop is what actually fits the container mental model. Two loops, two volumes, one shared concern — and the certs just renew themselves.

Need help shipping something like this? Get in touch.