├── scripts ├── utils.sh ├── certbot.sh └── acme.sh ├── .gitignore ├── Dockerfile ├── .gitattributes ├── LICENSE ├── entrypoint.sh └── readme.md /scripts/utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | change_cert_owner_and_restrict_perms(){ 4 | local cert_name="$1" 5 | local cert_dst="$cert_drop_path/$cert_name" 6 | 7 | # set owner 8 | chown -R root:$SSL_GROUP_ID "$cert_dst" 9 | 10 | # restrict permissions 11 | find "$cert_dst" -type d -exec chmod 755 {} + 12 | find "$cert_dst" -type f -exec chmod 644 {} + 13 | chmod -R o-rwx,g-w "$cert_dst/private" 14 | } 15 | 16 | drop_cert(){ 17 | local cert_name="$1" 18 | local cert_type="$2" 19 | local cert_dst="$cert_drop_path/$cert_name" 20 | 21 | find "$cert_dst" -type f -name "*.$cert_type.pem" -delete 22 | if [ "$(find "$cert_dst" -type f | wc -l)" -eq 0 ]; then 23 | rm -rf "$cert_dst" 24 | fi 25 | } 26 | 27 | log(){ 28 | printf "[certgen]: %b\n" "$1" 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/8ab86f6bb71e85b5046f1d921bbbe5ceec9063ba/Global/OSX.gitignore 2 | 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | 29 | ### https://raw.github.com/github/gitignore/8ab86f6bb71e85b5046f1d921bbbe5ceec9063ba/Global/Linux.gitignore 30 | 31 | *~ 32 | 33 | # KDE directory preferences 34 | .directory 35 | 36 | # Linux trash folder which might appear on any partition or disk 37 | .Trash-* 38 | 39 | 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM certbot/certbot:v0.20.0 2 | 3 | ENV \ 4 | DOMAINS="" \ 5 | RSA_ENABLED=1 \ 6 | ECDSA_ENABLED=1 \ 7 | ECDSA_KEY_LENGTH=ec-256 \ 8 | RSA_KEY_LENGTH=2048 \ 9 | CHALLENGE_MODE=standalone \ 10 | STAGING=1 \ 11 | FORCE_RENEWAL=0 \ 12 | SSL_GROUP_ID=1337 \ 13 | MUST_STAPLE=0 \ 14 | VERBOSE=0 15 | 16 | # internal variables not intended for override 17 | ENV \ 18 | PATH="${PATH}:/root/.acme.sh" \ 19 | CERT_HOME=/etc/acme \ 20 | LE_CONFIG_HOME=/etc/acme 21 | 22 | # Install acme.sh client 23 | RUN apk add --update curl openssl socat bash \ 24 | && curl -s https://raw.githubusercontent.com/Neilpang/acme.sh/7b8a82ce90c29cb50e88a33a3b61ca0f08469f64/acme.sh | INSTALLONLINE=1 sh \ 25 | && rm -rf /var/cache/apk/* 26 | 27 | COPY scripts /le-certgen/scripts 28 | COPY entrypoint.sh /le-certgen/entrypoint.sh 29 | 30 | VOLUME /var/ssl 31 | VOLUME /var/acme_challenge_webroot 32 | VOLUME /etc/letsencrypt 33 | VOLUME /etc/acme 34 | 35 | EXPOSE 80 36 | 37 | ENTRYPOINT ["/le-certgen/entrypoint.sh"] 38 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | #common settings that generally should always be used with your language specific settings 2 | 3 | # Auto detect text files and perform LF normalization 4 | # http://davidlaing.com/2012/09/19/customise-your-gitattributes-to-become-a-git-ninja/ 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # Documents 12 | *.doc diff=astextplain 13 | *.DOC diff=astextplain 14 | *.docx diff=astextplain 15 | *.DOCX diff=astextplain 16 | *.dot diff=astextplain 17 | *.DOT diff=astextplain 18 | *.pdf diff=astextplain 19 | *.PDF diff=astextplain 20 | *.rtf diff=astextplain 21 | *.RTF diff=astextplain 22 | *.md text 23 | *.adoc text 24 | *.textile text 25 | *.mustache text 26 | *.csv text 27 | *.tab text 28 | *.tsv text 29 | *.sql text 30 | 31 | # Graphics 32 | *.png binary 33 | *.jpg binary 34 | *.jpeg binary 35 | *.gif binary 36 | *.tif binary 37 | *.tiff binary 38 | *.ico binary 39 | # SVG treated as an asset (binary) by default. If you want to treat it as text, 40 | # comment-out the following line and uncomment the line after. 41 | *.svg binary 42 | #*.svg text 43 | *.eps binary -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Alexey Samoshkin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | if [ "$VERBOSE" -eq 1 ]; then 6 | set -x 7 | fi 8 | 9 | CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | source "$CURRENT_DIR/scripts/utils.sh" 11 | 12 | export webroot_path="/var/acme_challenge_webroot" 13 | export cert_drop_path="/var/ssl" 14 | export certbot_cert_home="/etc/letsencrypt/live" 15 | export acme_cert_home="$CERT_HOME" 16 | 17 | main(){ 18 | local command="$1" 19 | 20 | case "$command" in 21 | issue|revoke|renew|delete ) ;; 22 | noop|* ) 23 | log "noop or unknown command: $command. Do nothing" 24 | exit 0; 25 | ;; 26 | esac 27 | 28 | if [ -z "$DOMAINS" ]; then 29 | log "No domains specified. Use $DOMAIN variable" >&2 30 | exit 129; 31 | fi 32 | local domains_list=$([ -r "$DOMAINS" ] && cat "$DOMAINS" || echo "$DOMAINS"); 33 | 34 | log "Execute command: $command" 35 | log "Domains:\n\n $domains_list\n" 36 | log "Using 'certbot' client to handle RSA certificate. RSA_ENABLED: $RSA_ENABLED. Key length: $RSA_KEY_LENGTH" 37 | log "Using 'acme.sh' client to handle ECDSA certificate. ECDSA_ENABLED: $ECDSA_ENABLED. Key length: $ECDSA_KEY_LENGTH" 38 | 39 | printf '%s' "$domains_list" | while read -r domain || [[ -n "$domain" ]]; do 40 | [ "$RSA_ENABLED" -eq 1 ] && "$CURRENT_DIR/scripts/certbot.sh" "$command" "$domain" || true 41 | [ "$ECDSA_ENABLED" -eq 1 ] && "$CURRENT_DIR/scripts/acme.sh" "$command" "$domain" || true 42 | done 43 | 44 | chown -R root:$SSL_GROUP_ID "$cert_drop_path" 45 | 46 | log "Check out following path for certificates and keys: $cert_drop_path" 47 | } 48 | 49 | main "$@" -------------------------------------------------------------------------------- /scripts/certbot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | if [ "$VERBOSE" -eq 1 ]; then 6 | set -x 7 | fi 8 | 9 | CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | source "$CURRENT_DIR/utils.sh" 11 | 12 | default_certbot_args(){ 13 | [ "$VERBOSE" -eq 1 ] && echo "-v" || echo "" 14 | } 15 | 16 | save_certbot_cert(){ 17 | local cert_name="$1" 18 | local cert_src="$certbot_cert_home/$cert_name" 19 | local cert_dst="$cert_drop_path/$cert_name" 20 | 21 | mkdir -p "$cert_dst/certs" "$cert_dst/private"; 22 | 23 | cp -fL "$cert_src/cert.pem" "$cert_dst/certs/cert.rsa.pem"; 24 | cp -fL "$cert_src/chain.pem" "$cert_dst/certs/chain.rsa.pem"; 25 | cp -fL "$cert_src/fullchain.pem" "$cert_dst/certs/fullchain.rsa.pem"; 26 | cp -fL "$cert_src/privkey.pem" "$cert_dst/private/privkey.rsa.pem"; 27 | } 28 | 29 | build_challenge_mode_args(){ 30 | local mode="$1"; 31 | local args=""; 32 | 33 | if [ "$mode" == "webroot" ]; then 34 | args="--webroot -w $webroot_path" 35 | fi 36 | if [ "$mode" == "standalone" ]; then 37 | args="--standalone" 38 | fi 39 | 40 | echo $args; 41 | } 42 | 43 | issue_or_renew_rsa_cert(){ 44 | local issue_or_renew="$1" 45 | local domains="$2"; 46 | local cert_name="$3"; 47 | local email="$4"; 48 | 49 | local args=$(default_certbot_args) 50 | local args="$args $(build_challenge_mode_args "$CHALLENGE_MODE")" 51 | if [ "$STAGING" -eq 1 ]; then 52 | args="$args --staging"; 53 | fi 54 | 55 | if [ "$issue_or_renew" == "renew" ] && [ "$FORCE_RENEWAL" -eq 1 ]; then 56 | args="$args --force-renewal"; 57 | else 58 | args="$args --keep-until-expiring"; 59 | fi 60 | 61 | if [ "$MUST_STAPLE" -eq 1 ]; then 62 | args="$args --must-staple"; 63 | fi 64 | 65 | certbot certonly \ 66 | --non-interactive \ 67 | --cert-name "$cert_name" \ 68 | -d "$domains" \ 69 | -m "$email" \ 70 | --agree-tos \ 71 | --preferred-challenges http-01 \ 72 | --allow-subset-of-names \ 73 | --rsa-key-size "$RSA_KEY_LENGTH" \ 74 | $args 75 | } 76 | 77 | revoke_rsa_cert(){ 78 | local cert_name="$1" 79 | 80 | local args=$(default_certbot_args) 81 | if [ "$STAGING" -eq 1 ]; then 82 | args="$args --staging"; 83 | fi 84 | 85 | certbot revoke \ 86 | --non-interactive \ 87 | --agree-tos \ 88 | --cert-path "$certbot_cert_home/$cert_name/cert.pem" \ 89 | $args || true 90 | } 91 | 92 | delete_rsa_cert(){ 93 | local cert_name="$1" 94 | 95 | local args=$(default_certbot_args) 96 | if [ "$STAGING" -eq 1 ]; then 97 | args="$args --staging"; 98 | fi 99 | 100 | certbot delete \ 101 | --non-interactive \ 102 | --agree-tos \ 103 | --cert-name "$cert_name" \ 104 | $args || true 105 | } 106 | 107 | 108 | main() { 109 | local command="$1" 110 | local domains="$2"; 111 | local cert_name=${domains//,*/} 112 | local email="admin@$cert_name"; 113 | 114 | if [ "$command" == "issue" ] || [ "$command" == "renew" ]; then 115 | log "Issue/renew RSA certificate '$cert_name' for domains '$domains'" 116 | 117 | issue_or_renew_rsa_cert "$command" "$domains" "$cert_name" "$email" 118 | save_certbot_cert "$cert_name" 119 | change_cert_owner_and_restrict_perms "$cert_name" 120 | fi 121 | 122 | if [ "$command" == "revoke" ]; then 123 | log "Revoke RSA certificate '$cert_name' for domains '$domains'" 124 | 125 | revoke_rsa_cert "$cert_name" 126 | fi 127 | 128 | if [ "$command" == "delete" ]; then 129 | log "Delete RSA certificate '$cert_name' for domains '$domains' " 130 | 131 | delete_rsa_cert "$cert_name" 132 | drop_cert "$cert_name" "rsa" 133 | fi 134 | } 135 | 136 | main "$@" -------------------------------------------------------------------------------- /scripts/acme.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | if [ "$VERBOSE" -eq 1 ]; then 6 | set -x 7 | fi 8 | 9 | CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | source "$CURRENT_DIR/utils.sh" 11 | 12 | default_acme_args(){ 13 | [ "$VERBOSE" -eq 1 ] && echo "--debug" || echo "" 14 | } 15 | 16 | save_acme_cert(){ 17 | local cert_name="$1" 18 | local cert_dst="$cert_drop_path/$cert_name" 19 | 20 | mkdir -p "$cert_dst/certs" "$cert_dst/private"; 21 | 22 | acme.sh \ 23 | --install-cert -d "$cert_name" \ 24 | --ecc \ 25 | --cert-file "$cert_dst/certs/cert.ecc.pem" \ 26 | --ca-file "$cert_dst/certs/chain.ecc.pem" \ 27 | --fullchain-file "$cert_dst/certs/fullchain.ecc.pem" \ 28 | --key-file "$cert_dst/private/privkey.ecc.pem" 29 | } 30 | 31 | build_domains_args(){ 32 | local domains="$1" 33 | 34 | echo "$domains" | awk 'BEGIN {RS=","; ORS=" "} { print "-d",$0}' 35 | } 36 | 37 | build_challenge_mode_args(){ 38 | local mode="$1"; 39 | local args=""; 40 | 41 | if [ "$mode" == "webroot" ]; then 42 | args="-w $webroot_path" 43 | fi 44 | if [ "$mode" == "standalone" ]; then 45 | args="--standalone" 46 | fi 47 | 48 | echo $args; 49 | } 50 | 51 | issue_esdca_cert(){ 52 | local domains="$1"; 53 | local cert_name="$2"; 54 | local email="$3"; 55 | 56 | local args=$(default_acme_args) 57 | local args="$args $(build_domains_args "$domains")" 58 | args="$args $(build_challenge_mode_args "$CHALLENGE_MODE")" 59 | if [ "$STAGING" -eq 1 ]; then 60 | args="$args --staging"; 61 | fi 62 | 63 | if [ "$MUST_STAPLE" -eq 1 ]; then 64 | args="$args --ocsp-must-staple"; 65 | fi 66 | 67 | set +e 68 | acme.sh \ 69 | --issue \ 70 | --ecc \ 71 | --keylength "$ECDSA_KEY_LENGTH" \ 72 | $args 73 | 74 | local retval=$?; 75 | if [ "$retval" -ne 2 ] && [ "$retval" -ne 0 ]; then 76 | log "Issue failed for unknown reasons. acme.sh error code: $retval" >&2 77 | exit $retval; 78 | fi 79 | 80 | set -e 81 | } 82 | 83 | renew_ecdsa_cert(){ 84 | local domains="$1"; 85 | local cert_name="$2"; 86 | 87 | local args=$(default_acme_args) 88 | local args="$args $(build_domains_args "$domains")" 89 | args="$args $(build_challenge_mode_args "$CHALLENGE_MODE")" 90 | if [ "$STAGING" -eq 1 ]; then 91 | args="$args --staging"; 92 | fi 93 | if [ "$FORCE_RENEWAL" -eq 1 ]; then 94 | args="$args --force"; 95 | fi 96 | 97 | if [ "$MUST_STAPLE" -eq 1 ]; then 98 | args="$args --ocsp-must-staple"; 99 | fi 100 | 101 | set +e 102 | acme.sh \ 103 | --renew \ 104 | --ecc \ 105 | --keylength "$ECDSA_KEY_LENGTH" \ 106 | $args 107 | 108 | local retval=$?; 109 | if [ "$retval" -ne 2 ] && [ "$retval" -ne 0 ]; then 110 | log "Renew failed for unknown reasons. acme.sh error code: $retval" >&2 111 | exit $retval; 112 | fi 113 | 114 | set -e 115 | } 116 | 117 | revoke_ecdsa_cert(){ 118 | local cert_name="$1"; 119 | 120 | local args=$(default_acme_args) 121 | local args="$args -d $cert_name" 122 | if [ "$STAGING" -eq 1 ]; then 123 | args="$args --staging"; 124 | fi 125 | 126 | acme.sh \ 127 | --revoke \ 128 | --ecc \ 129 | $args || true 130 | } 131 | 132 | delete_ecdsa_cert(){ 133 | local cert_name="$1"; 134 | 135 | local args=$(default_acme_args) 136 | local args="$args -d $cert_name" 137 | if [ "$STAGING" -eq 1 ]; then 138 | args="$args --staging"; 139 | fi 140 | 141 | acme.sh \ 142 | --remove \ 143 | --ecc \ 144 | $args || true 145 | 146 | rm -rf "$acme_cert_home/${cert_name}_ecc" 147 | } 148 | 149 | main() { 150 | local command="$1" 151 | local domains="$2"; 152 | local cert_name=${domains//,*/} 153 | local email="admin@$cert_name"; 154 | 155 | if [ "$command" == "issue" ]; then 156 | log "Issue ECDSA certificate '$cert_name' for domains '$domains'" 157 | 158 | issue_esdca_cert "$domains" "$cert_name" "$email" 159 | save_acme_cert "$cert_name" 160 | change_cert_owner_and_restrict_perms "$cert_name" 161 | fi 162 | 163 | if [ "$command" == "renew" ]; then 164 | log "Renew ECDSA certificate '$cert_name' for domains '$domains'" 165 | 166 | renew_ecdsa_cert "$domains" "$cert_name" 167 | save_acme_cert "$cert_name" 168 | change_cert_owner_and_restrict_perms "$cert_name" 169 | fi 170 | 171 | if [ "$command" == "revoke" ]; then 172 | log "Revoke ECDSA certificate '$cert_name'" 173 | 174 | revoke_ecdsa_cert "$cert_name" 175 | fi 176 | 177 | if [ "$command" == "delete" ]; then 178 | log "Delete ECDSA certificate '$cert_name'" 179 | 180 | delete_ecdsa_cert "$cert_name" 181 | drop_cert "$cert_name" "ecc" 182 | fi 183 | } 184 | 185 | main "$@" -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | docker-letsencrypt-certgen 2 | ========================== 3 | 4 | [Docker image](https://hub.docker.com/r/asamoshkin/letsencrypt-certgen/) allowing to generate, renew, revoke RSA and/or ECDSA SSL certificates from [LetsEncrypt CA](https://letsencrypt.org/) using [certbot](https://certbot.eff.org/) and [acme.sh](https://github.com/Neilpang/acme.sh) clients in automated fashion. 5 | 6 | See also my blog post [RSA and ECDSA hybrid Nginx setup with LetsEncrypt certificates](https://medium.com/@alexeysamoshkin/rsa-and-ecdsa-hybrid-nginx-setup-with-letsencrypt-certificates-ee422695d7d3) that shows a primer for this docker image. 7 | 8 | ![logo](https://user-images.githubusercontent.com/768858/34629566-42e2a3f8-f271-11e7-9387-6e3662a67148.png) 9 | 10 | Goal 11 | ---- 12 | This project might be one you're looking for, if: 13 | 14 | - you need to obtain new SSL certificate for your new shiny domain/website 15 | - you need simple domain validated (DV) certificate, and you don't want to pay money to Certificate Authorities (CA), like DigiCert or Symantec for humble DV certificates. 16 | - you've heard about [LetsEncrypt CA](https://letsencrypt.org/), which allows to automate issueance of free DV certificates, but you're lazy enough to learn in-depth and don't want to spent much time there. 17 | - you need to have both RSA and ECDSA certificates 18 | - you're learning LetsEncrypt and want to check out [certbot](https://certbot.eff.org/) and [acme.sh](https://github.com/Neilpang/acme.sh) clients usage primers 19 | - you're using Docker to deploy/run your app and services, and you're going to automate process of certificate issueance and/or renewal (e.g as a part of CI/CD process). 20 | 21 | So, this Docker image provides a simple single entrypoint to obtain and manage SSL certificates from LetsEncrypt CA. It encapsulates two popular ACME clients: [certbot](https://certbot.eff.org/) and [acme.sh](https://github.com/Neilpang/acme.sh), which are used to obtain RSA and/or ECDSA certificates respectively. 22 | 23 | Following single responsibilty principle, this image cares only about how to talk to LetsEncrypt CA to provide you with a certificate, and it's completely unaware and not coupled with web server software or any other infrastructure service. This approach makes it a more versatile tool and unlocks greater number of use cases. 24 | 25 | You can use it ad-hoc at a build time, at a run-time prior to Nginx/Apache startup, or by running it from cron job to renew certificates on regular basis. LetsEncrypt stuff stays within a single container, and you don't need to pollute your Nginx/Apache container. 26 | 27 | 28 | Features 29 | -------- 30 | 31 | Here is a list of notable features: 32 | 33 | - automate issueance and managing LetsEncrypt SSL certificates 34 | - generate DV certificates for 1..N domains 35 | - support multi-domain SAN (Subject alternative names) certificates 36 | - generate RSA and/or ECDSA certificate with configurable key params: RSA key length (2048, 3072, 4096) and elliptic curve for EC key (prime256v1, secp384r1) 37 | - choose DV challenge verification method: standalone or webroot 38 | - renew certificates when they're about to expire or force renewal 39 | - revoke certificates by contacting LetsEncrypt CA 40 | - use either LetsEncrypt staging or production server 41 | 42 | Prerequisites 43 | ------------- 44 | It's assumed you already have a domain name, a server, and a working DNS configuration with at least "A" record mapping name to your server's IP address. 45 | 46 | In a standalone mode, you need to run this image on that server, with 80 port opened by firewall, so ACME http-01 challenge verification succeeds. 47 | 48 | Getting started 49 | --------------- 50 | Let's say I have `foobbz.site` domain, DigitalOcean droplet with running Docker Engine (188.166.168.213), and DNS "A" record "foobbz.site"->"188.166.168.213". 51 | 52 | Let's issue new RSA (2048 bit length) and ECDSA (prime256v1 curve) certificate for single domain "foobbz.site": 53 | 54 | ``` 55 | docker run \ 56 | -v /var/ssl:/var/ssl \ 57 | -p 80:80 \ 58 | -e DOMAINS=foobbz.site \ 59 | --rm \ 60 | asamoshkin/letsencrypt-certgen issue 61 | ``` 62 | 63 | Once done, container stops and is automatically removed (--rm). Certificates, keys and related files are stored in `/var/ssl/foobbz.site`: 64 | 65 | ``` 66 | # tree /var/ssl 67 | 68 | /var/ssl 69 | └── foobbz.site 70 | ├── certs 71 | │   ├── cert.ecc.pem 72 | │   ├── cert.rsa.pem 73 | │   ├── chain.ecc.pem 74 | │   ├── chain.rsa.pem 75 | │   ├── fullchain.ecc.pem 76 | │   └── fullchain.rsa.pem 77 | └── private 78 | ├── privkey.ecc.pem 79 | └── privkey.rsa.pem 80 | ``` 81 | 82 | All files are encoded in PEM format. 83 | 84 | - `cert.rsa.pem`, `cert.ecc.pem` - generated certificate (RSA or ECDSA) 85 | - `chain.[type].pem` - chain of intermediate CA certificates (e.g. Fake LE Intermediate X1) 86 | - `fullchain.[type].pem` - generated certificate bundled with intermediate CA certificates. Suitable for Nginx configuration directive `ssl_certificate`, which should point to a bundle, instead of individual certificate. 87 | - `privkey.[type].pem` - private key file 88 | 89 | Given that, you can then mount `/var/ssl:/etc/nginx/ssl` into Nginx container and configure it to use RSA or ECDSA key or even both. 90 | 91 | ``` 92 | # RSA certificates 93 | ssl_certificate /etc/nginx/ssl/foobbz.site/certs/fullchain.rsa.pem; 94 | ssl_certificate_key /etc/nginx/ssl/foobbz.site/private/privkey.rsa.pem; 95 | 96 | # ECDSA certificates 97 | ssl_certificate /etc/nginx/ssl/foobbz.site/certs/fullchain.ecc.pem; 98 | ssl_certificate_key /etc/nginx/ssl/foobbz.site/private/privkey.ecc.pem; 99 | ``` 100 | 101 | Multiple certificates and multi-domain SAN certificates 102 | ------------------------------------------------- 103 | 104 | You're not limited to certificate with single domain only. You can generate several individual certificates for different domains. Or you can have single multi-domain SAN (Subject Alternate Names) certificate. Or both. 105 | 106 | Prepare `domains.txt` file. Each line represents individual certificate to be issued. First name within each line is a common name, subsequent comma-separated names are certificate alternative names. 107 | 108 | ``` 109 | # cat /root/domains.txt 110 | 111 | foobbz.site,www.foobbz.site,web.foobbz.site 112 | foobbz2.site,www.foobbz.site 113 | ``` 114 | 115 | Tell container to pick up domains list from `domains.txt`. `$DOMAINS` variable is double-purpose: it indicates either domains list as a string or points to a file with a domains list. 116 | 117 | ``` 118 | docker run \ 119 | -v /var/ssl:/var/ssl \ 120 | -v /root/domains.txt:/etc/domains.txt \ 121 | -p 80:80 \ 122 | -e DOMAINS=/etc/domains.txt \ 123 | --rm \ 124 | asamoshkin/letsencrypt-certgen issue 125 | ``` 126 | 127 | 128 | 129 | As a result, we have 2 individual certificates generated: 130 | 131 | ``` 132 | ls /var/ssl 133 | 134 | foobbz.site 135 | foobbz2.site 136 | ``` 137 | 138 | And let's check out how multiple domains are stored in the certificate in X.509 SAN extension. 139 | 140 | ``` 141 | docker run -v /var/ssl:/var/ssl --entrypoint sh --rm -it alpine 142 | / # apk --update add openssl 143 | / # openssl x509 -in /var/ssl/foobbz.site/certs/cert.rsa.pem -noout -text 144 | ``` 145 | 146 | ``` 147 | Issuer: CN=Fake LE Intermediate X1 148 | Subject: CN=foobbz.site 149 | ... 150 | X509v3 extensions: 151 | X509v3 Subject Alternative Name: 152 | DNS:foobbz.site, DNS:web.foobbz.site, DNS:www.foobbz.site 153 | ``` 154 | 155 | Volumes and managing your certificates 156 | -------------------------------------- 157 | Each LetsEncrypt client (certbot, acme.sh) manages its own place to store certificates, keys, account keys and various settings. You need to ensure this location is stored outside of the container for persistency. 158 | 159 | ``` 160 | docker volume create --name ssl 161 | docker volume create --name acme 162 | docker volume create --name letsencrypt 163 | 164 | docker run \ 165 | -v ssl:/var/ssl \ 166 | -v acme:/etc/acme \ 167 | -v letsencrypt:/etc/letsencrypt \ 168 | -p 80:80 \ 169 | -e DOMAINS=foobbz.site \ 170 | --rm \ 171 | asamoshkin/letsencrypt-certgen issue 172 | ``` 173 | 174 | `/etc/acme` and `/etc/letsencrypt` are just internal storages of `acme.sh` and `certbot` clients, which are used under the hood. They contain certificates, keys, various settings, but we don't use them directly as their structure varies and is a subject to change. Therefore, `/var/ssl` volume serves as a target drop location for certificates and keys. You should mount `/var/ssl` into any container, that needs certificates (e.g. Nginx). 175 | 176 | Once you enabled persistency for "certbot" and "acme.sh" clients internal storage, you can perform management actions, like renewing, revoking or deleting a certificate. 177 | 178 | LetsEncrypt CA issues short-lived certificates which are only valid for 90 days. While renewing, it will check certificate validity period. If it's not due to expire (more than 1 month before expiration date), existing certificate will be kept. You can force renewal: 179 | 180 | ``` 181 | docker run \ 182 | -v ssl:/var/ssl \ 183 | -v acme:/etc/acme \ 184 | -v letsencrypt:/etc/letsencrypt \ 185 | -p 80:80 \ 186 | -e DOMAINS=foobbz.site \ 187 | -e FORCE_RENEWAL=1 \ 188 | --rm \ 189 | asamoshkin/letsencrypt-certgen renew 190 | ``` 191 | 192 | Use `revoke` or `delete` commands to trigger respective actions. Use `$DOMAINS` variable to specify particular domain to revoke or delete. It's ok to tell just common name, no need to specify all alternative names, as you did for `issue` command. 193 | 194 | When revoking certificate, it will not remove files neither from internal storages, nor from `/var/ssl` volume. On the other hand, deleting certificate removes from both locations, but do not revoke certificate by contacting LetsEncrypt CA. 195 | 196 | Challenge verification method: standalone vs webroot 197 | ----------------------------------------------------- 198 | 199 | When issuing certificate, CA needs to verify domain ownership. This project uses simple `http-01` method. 200 | 201 | Here is how it works. LetsEncrypt client creates a special file. CA contacts `foobbz.site` domain on port 80 with `GET /.well-known/acme-challenge` request for that file. If request succeeds, it proves the domain ownership. 202 | 203 | In standalone mode, during challenge verification `certbot` or `acme.sh` spin up an embedded web server, which listens on port 80 and is capable of serving that file. This is a default setting. 204 | 205 | If you already have a running web server on port 80, you can opt for `webroot` mode. `acme.sh` or `certbot` will just store the file at predefined location, and your web server will handle serving it from that location at particular url. 206 | 207 | Create a dedicated volume: 208 | ``` 209 | docker volume create --name acme_challenge_webroot 210 | ``` 211 | 212 | When running Nginx container, make sure to mount it: 213 | ``` 214 | docker run \ 215 | -v ssl:/etc/nginx/ssl 216 | -v acme_challenge_webroot:/var/www/acme_challenge_webroot 217 | -p 80:80 \ 218 | -p 443:443 \ 219 | --name web 220 | my-nginx-container 221 | ``` 222 | 223 | Configure Nginx to serve `/.well-known/acme-challenge` requests from that volume: 224 | 225 | ``` 226 | server { 227 | listen 80; 228 | server_name foobbz.site www.foobbz.site; 229 | 230 | location ^~ /.well-known/acme-challenge { 231 | allow all; 232 | root /var/www/acme_challenge_webroot; 233 | default_type text/plain; 234 | } 235 | } 236 | ``` 237 | 238 | Finally, run this image in `webroot` mode to issue/renew certificates. Tip: you can do this from cron job to renew on regular basis. Note, when using `webroot` method, there is no need to expose 80 port on this container any more. 239 | 240 | ``` 241 | docker run \ 242 | -v ssl:/var/ssl \ 243 | -v acme:/etc/acme \ 244 | -v letsencrypt:/etc/letsencrypt \ 245 | -v acme_challenge_webroot:/var/acme_challenge_webroot \ 246 | -e DOMAINS=foobbz.site \ 247 | -e CHALLENGE_MODE=webroot \ 248 | --rm \ 249 | asamoshkin/letsencrypt-certgen renew 250 | ``` 251 | 252 | Main use case for `webroot` method, is the ability to renew certificates, without a need to stop you existing web server and running applications. 253 | 254 | 255 | RSA and ECDSA certificates 256 | -------------------------- 257 | `certbot` is not capable of generating ECDSA yet (except from custom CSR). So, `cerbot` is used for RSA, whereas `acme.sh` is for ECDSA. 258 | 259 | Default is to generate both. But you can disable one or another using `$RSA_ENABLED` and `$ECDSA_ENABLED` environment variables. 260 | 261 | Also, you can configure RSA key length: 2048, 3072 or 4096. For ECDSA key, you can tell elliptic curve: prime256v1 (ec-256), secp384r1 (ec-384), secp521r1 (ec-521, not yet supported by LetsEncrypt CA). 262 | 263 | For example: 264 | 265 | ``` 266 | docker run \ 267 | -v ssl:/var/ssl \ 268 | -p 80:80 \ 269 | -e DOMAINS=foobbz.site \ 270 | -e RSA_ENABLED=0 271 | -e ECDSA_KEY_LENGTH=ec-384 272 | --rm \ 273 | asamoshkin/letsencrypt-certgen renew 274 | ``` 275 | 276 | Note, that ECDSA certificates are still signed by LetsEncrypt's RSA certificate chain (Fake LE Intermediate X1, Fake LE Root X1). LetsEncrypt does not use dedicated EC certificates to sign for complete EC chain. 277 | 278 | You might also request certificate with [OCSP must-staple](https://scotthelme.co.uk/ocsp-must-staple/) extension, by passing `MUST_STAPLE=1` environment variable. Yes, LetsEncrypt supports issuing certificates with OCSP must-staple flag. 279 | 280 | Using LetsEncrypt staging server 281 | -------------------------------- 282 | Be aware, that LetsEncrypt CA production servers put strict [rate limits](https://letsencrypt.org/docs/rate-limits/): 283 | 284 | - certificates per Registered Domain (20 per week) 285 | - up to 100 alternative names per certificate 286 | - duplicate certificate limit of 5 certificates per week 287 | 288 | While you're trying and experimenting, it's better to use LetsEncrypt [staging environment](https://letsencrypt.org/docs/staging-environment/) with much relaxed limits: 289 | 290 | - The Certificates per Registered Domain limit is 30,000 per week. 291 | - The Duplicate Certificate limit is 30,000 per week. 292 | - The Failed Validations limit is 60 per hour. 293 | - The Accounts per IP Address limit is 50 accounts per three 3 hour period per IP. 294 | 295 | Using staging server is a default option here. To switch to production servers, set `STAGING=0` environment variable. 296 | 297 | `/var/ssl` volume permissions and ownership 298 | ------------------------------------------- 299 | When using sharing volumes, permissions and ownership issue needs to be resolved. 300 | 301 | It's a good practice to restrict permissions for private key files, so it's not world accessible (`umask 007`), or even group accessible (`umask 077`). On the other hand, we need to make sure that SSL certificates/keys can be read when mounted into another container, which runs as a less-priviledged non-root user (Nginx running as nginx:nginx). 302 | 303 | The solution is to set group ownership to a dedicated GID, and let less-priviledged user in other containers join that group to access files. When running container, you can override GID (default is `1337`): 304 | 305 | ``` 306 | docker run \ 307 | -v ssl:/var/ssl \ 308 | -p 80:80 \ 309 | -e DOMAINS=foobbz.site \ 310 | -e SSL_GROUP_ID=1561 311 | --rm \ 312 | asamoshkin/letsencrypt-certgen renew 313 | ``` 314 | 315 | Check out permissions and ownership of created certificates: 316 | 317 | ``` 318 | tree -pug /var/ssl 319 | 320 | /var/ssl 321 | └── [drwxr-xr-x root 1561] foobbz.site 322 | ├── [drwxr-xr-x root 1561] certs 323 | │   ├── [-rw-r--r-- root 1561] cert.ecc.pem 324 | │   ├── [-rw-r--r-- root 1561] cert.rsa.pem 325 | │   ├── [-rw-r--r-- root 1561] chain.ecc.pem 326 | │   ├── [-rw-r--r-- root 1561] chain.rsa.pem 327 | │   ├── [-rw-r--r-- root 1561] fullchain.ecc.pem 328 | │   └── [-rw-r--r-- root 1561] fullchain.rsa.pem 329 | └── [drwxr-x--- root 1561] private 330 | ├── [-rw-r----- root 1561] privkey.ecc.pem 331 | └── [-rw-r----- root 1561] privkey.rsa.pem 332 | ``` 333 | 334 | You can see that all files has `root:1561` ownership. Note, it's not required to create a real group in `/etc/group`, it's enough to just assign numeric GIDs. 335 | 336 | `/var/ssl/foobbz.site/private` directory has `750` perm mode, and key files inside it are `640`. So it's not world accessible, and only user with a dedicated group membership can read those files. 337 | 338 | Docker compose sugar 339 | -------------------- 340 | When trying or experimenting with this image, it becomes tough to type long `docker run` commands. Use `docker-compose` instead: 341 | 342 | ``` 343 | docker-compose build && docker-compose run --rm -p 80:80 certgen issue 344 | ``` 345 | 346 | And `docker-compose.yml` file looks like: 347 | 348 | ``` 349 | version: '2' 350 | 351 | services: 352 | certgen: 353 | image: samoshkin/letsencrypt-certgen 354 | environment: 355 | - DOMAINS=foobbz.site,www.foobbz.site,web.foobbz.site 356 | - VERBOSE=1 357 | volumes: 358 | - letsencrypt:/etc/letsencrypt 359 | - acme:/etc/acme 360 | - ssl:/var/ssl 361 | - acme_challenge_webroot:/var/acme_challenge_webroot 362 | volumes: 363 | letsencrypt: 364 | acme: 365 | ssl: 366 | acme_challenge_webroot: 367 | ``` 368 | --------------------------------------------------------------------------------