Search code examples
dockerdocker-composessl-certificatetraefik

Unable to obtain ACME certificate for domains - trying to setup HTTPS in virtual localhost on docker


I am trying to setup a certificate for a locally running react app on a virtual host local.example.com. This has to just work locally on docker setup. After going through some articles, I came up with this docker-compose.yml:

version: "3"
services:
  mongo:
    image: mongo
    restart: always
    ports:
      - "27017:27017"
    volumes:
      - mongodbdata:/data/db
    networks:
      - proxy
  mongo-express:
    image: mongo-express
    restart: always
    ports:
      - "8081:8081"
    networks:
      - proxy
  react:
    build:
      context: ./client
      dockerfile: ./Dockerfile
    ports:
      - "3001:3001"
    stdin_open: true
    volumes:
      - ./client:/client
      - /client/node_modules
    labels:
      # this enables traefik for your service
      - "traefik.enable=true"
      # this defines the url, traefik will get the ssl certificate for
      - "traefik.http.routers.myapplication.rule=Host(`local.example.com`)"
      # this tells traefik to use https to access the website
      - "traefik.http.routers.myapplication.entrypoints=websecure"
      # this tells traefik to use the certresolver, that we defined above for resolving tls (in our case letsencrypt)
      - "traefik.http.routers.myapplication.tls.certresolver=myresolver"
      # this let's us forward the port we set above. Change this to the port you expose in your application (3000, 4000, ...) or remove the line, if your application already exposes port 80/443
      - "traefik.http.services.myapplication.loadbalancer.server.port=3000"
    depends_on:
      - "server"
    networks:
      - proxy
  server:
    build:
      context: ./server
      dockerfile: ./Dockerfile
    ports:
      - "5001:5001"
    volumes:
      - traefik.toml:/traefik.toml
      - acme.json:/acme.json
      - ./server:/server
      - /server/node_modules
    labels:
      # this enables traefik for your service
      - "traefik.enable=true"
      # this defines the url, traefik will get the ssl certificate for
      - "traefik.http.routers.myapplication.rule=Host(`local.example.com`)"
      # this tells traefik to use https to access the website
      - "traefik.http.routers.myapplication.entrypoints=websecure"
      # this tells traefik to use the certresolver, that we defined above for resolving tls (in our case letsencrypt)
      - "traefik.http.routers.myapplication.tls.certresolver=myresolver"
      # this let's us forward the port we set above. Change this to the port you expose in your application (3000, 4000, ...) or remove the line, if your application already exposes port 80/443
      - "traefik.http.services.myapplication.loadbalancer.server.port=5001"
    depends_on:
      - "mongo"
    networks:
      - proxy
  whoami:
    image: "containous/whoami"
    container_name: "myapplication"
    restart: unless-stopped
    ports:
      - "4000:4000"
    networks:
      - proxy

  traefik:
    image: "traefik:v2.2"
    container_name: "traefik"
    command:
      # this can be uncommented to get more information, in case something doesn't work
      - "--log.level=DEBUG"
      # set this to true to get access to the traefik web interface unter http://YOURIP:8080
      - "--api.insecure=false"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=true"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.myresolver.acme.httpchallenge=true"
      - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
        #- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" # uncomment this line to only test ssl generation first (to make sure you don't run into letsencrypt limits)
      - "--certificatesresolvers.myresolver.acme.email=kamlekar.venkatesh@gmail.com"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080" # this is used for the web interface, that let's you check and monitor traefik and your configuration. It's very nice for debugging your config - only available if "api.insecure" above is set to true
    volumes:
      - "./letsencrypt:/letsencrypt"
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    networks:
      - proxy
    # The following is only necessary if you want to enforce https!
    # if you don't need that, you can just remove the labels here
    labels:
      - "traefik.http.routers.http-catchall.rule=hostregexp(`{host:.+}`)"
      - "traefik.http.routers.http-catchall.entrypoints=web"
      - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"

networks:
  proxy:
    external: true

volumes:
  mongodbdata:
  traefik.toml:
  acme.json:

On doing docker-compose up, I am seeing the following error in traefik service. Also, though the react app service is running in docker but I hit https://local.example.com:3001, is unreachable

time="2022-01-23T08:30:52Z" level=error msg="Unable to obtain ACME certificate for domains "local.example.com": unable to generate a certificate for the domains [local.example.com]: error: one or more domains had a problem:\n[local.example.com] acme: error: 400 :: urn:ietf:params:acme:error:dns :: DNS problem: NXDOMAIN looking up A for local.example.com - check that a DNS record exists for this domain; DNS problem: NXDOMAIN looking up AAAA for local.example.com - check that a DNS record exists for this domain, url: \n" providerName=myresolver.acme routerName=myapplication@docker rule="Host(local.example.com)"

Here are the research notes which I tried till now (These notes for only my personal understanding so could be not in detail)

You can start from this fiddle: https://github.com/kamlekar/react-docker-ssl-virtualhost


Solution

  • You need to use TLS for your local setup. The host you need a certificate for is local.example.com. There is no way to obtain a certificate from Letsencrypt for this name, because you're not controlling the example.com domain. One of the ways Letsencrypt creates a certificate is a challenge - you prove that you own the domain by creating a TXT DNS record. If you own a domain you can do that, but your case is different, because you only need this for local development.

    However, you can just use openssl to generate a self signed certificate for whichever domain name you want. This is a good reference on how to do this. You can use the local.example.com domain name for the generated certificate. If you're successful, you'll end up with the certificate and it's private key. Note where you save those files, as you'll need them. Keep in mind that the certificate is self-signed, so your browser will give you a warning, unless you add this certificate to the trust store of your operating system.

    The next step in your case is to make Traefik use those self signed certificates when serving content from your application. I think this answer has a good example of that.

    After having this, you'll only need to edit your hosts file and redirect your localhost:8080 (the port on which your Traefik serves your application) to local.example.com.

    Also, Traefik is not the only solution for your case. You can also achieve the same using Nginx, for example. Choose which one satisfies your use case. My suggestion would be to use the one that's easiest to configure, because it's for local development. Here's the first result I got when searching for a nginx docker-compose self-signed certificate.

    UPDATE
    Here's a quick example of what I'm describing above.
    First generate the certificate:

    openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -sha256 -days 1000 -subj '/CN=local.example.com'
    

    You'll end up with two files in your current directory (key.pem and cert.pem). Now create the nginx.conf:

    worker_processes 1;
    
    events {
        worker_connections 1024;
    }
    
    http {
        server {
            listen       443 ssl;
            server_name  local.example.com;
    
            ssl_certificate      cert.pem;
            ssl_certificate_key  key.pem;
    
            ssl_session_timeout  5m;
    
            location / {
              proxy_pass               http://myapp:8080;
              proxy_set_header         Host     $host;
              proxy_read_timeout       1800;
              proxy_connect_timeout    1800;
            }
        }
    }
    

    And now the docker-compose.yaml file:

    version: "3"
    
    services:   
      nginx:
        image: nginx
        volumes:
          - ./nginx.conf:/etc/nginx/nginx.conf
          - ./cert.pem:/etc/nginx/cert.pem
          - ./key.pem:/etc/nginx/key.pem
        ports:
          - "443:443"
        networks:
          - local-dev-01
    
      myapp:
        image: your-react-app-image
        command: "command-that-starts-your-app"
        networks:
          - local-dev-01
    
    networks:   
      local-dev-01:
    

    Docker creates a network called local-dev-01 for you, which allows both services to be able to resolve each other by their name. That's why we have myapp:8080 in the nginx.conf. We also mount the configuration and the generated certificate and key for local.example.com.
    The final step is to edit your hosts file and add the following line:

    127.0.0.1      local.example.com
    

    After that, you should have no trouble reaching your application on https://local.example.com on your machine. Keep in mind that your browser will keep warning you that the certificate is self-signed, so you should add it as an exception.