Update: Blog post using the acme_certificate module can be found here .
Ansible comes with a plugin which allows to create and renew Let’s Encrypt certificates. Documentation is sparse, so I decided to post about my own Playbook.
For my case, I use the http-01
challenge, but the plugin allows to use dns-01
and tls-sni-02
challenges as well. When using the plugin, it needs to be called twice in a Playbook. The first run will create the challenge, and the second time - using the input from the first run - will answer the challenge and create the certificates.
To keep things easy, I don’t combine multiple hostnames into a certificate, but create a single certificate per host. If that approach is used for a large number of hostnames, one might run into the Let’s Encrypt rate limit . The challenge handling is done on the Ansible host, not on the remote host.
Also my Playbook is split into two parts. One part just controls the hostnames, and includes another file which does the actual work.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
---
# Let's Encrypt certificates
- hosts : letsencrypt
become : yes
gather_facts : True
vars :
letsencrypt_account_email : "your@email.here"
# staging/testing
letsencrypt_acme_directory : "https://acme-staging.api.letsencrypt.org/directory"
# production
#letsencrypt_acme_directory: "https://acme-v01.api.letsencrypt.org/directory"
letsencrypt_acme_version : 1
letsencrypt_challenge : "http-01"
letsencrypt_agreement : "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
letsencrypt_remaining_days : 45
letsencrypt_cert_dir : "{{ playbook_dir }}/letsencrypt-certificates"
tasks :
# this will delete local outdated challenge files
- name : Find outdated challenge files
local_action :
module : find
paths : "{{ playbook_dir }}/letsencrypt-challenges/"
recurse : no
file_type : file
age : "1h"
register : outdated_challenge_files
become : no
run_once : true
- name : Delete outdated challenge files
local_action :
module : file
state : absent
path : "{{ item.path }}"
with_items : "{{ outdated_challenge_files.files }}"
become : no
run_once : true
- name : Handle certificate
include : "{{ playbook_dir }}/letsencrypt.yml"
with_items :
- host1.com
- www.host1.com
- host2.com
- www.host2.com
loop_control :
loop_var : domain
handlers :
- name : reload apache2
service : name=apache2 state=restarted
This first part will delete all old challenge files (older than one hour) from the Ansible host. That’s done by using the local_action module. It also set’s a number of variables, most notably the email address, the used ACME version and the endpoint. To keep things safe, the plugin will connect to the staging endpoint by default - this results in certificates which are not recognized by the browsers, but that’s fine for testing. Once a production ready certificate is desired, the endpoint (specified in letsencrypt_acme_directory
) must be adjusted.
All the other work is done in the letsencrypt.yml
file, and the hostname is specified in the {{ domain }}
variable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
---
# create the domain directory for the cert files on localhost
- name : create local cert directory
local_action :
module : file
path : "{{ letsencrypt_cert_dir }}/{{ domain }}"
mode : 0700
state : directory
become : no
run_once : true
- name : Create web domain directory
file :
path : "/srv/www/{{ domain }}"
state : directory
owner : root
group : root
mode : 0755
- name : Create cert domain directory
file :
path : "/etc/certs/{{ domain }}"
state : directory
owner : www-data
group : www-data
mode : 0750
- name : Create web domain acme-challenge directory
file :
path : "/srv/www/{{ domain }}/.well-known/acme-challenge"
state : directory
owner : root
group : root
mode : 0755
- name : Find outdated challenge files
find :
paths : "/srv/www/{{ domain }}/.well-known/acme-challenge"
recurse : no
file_type : file
age : "1h"
register : outdated_challenge_files
- name : Delete outdated challenge files
file :
state : absent
path : "{{ item.path }}"
with_items : "{{ outdated_challenge_files.files }}"
# generate account key, if necessary
- name : Find account.key
local_action :
module : stat
path : "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
register : account_key
become : no
run_once : true
- name : Generate account.key
local_action : shell openssl genrsa 4096 > "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
become : no
run_once : true
when : account_key.stat.exists == False
# generate domain key, if necessary
- name : Find domain.key
local_action :
module : stat
path : "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key"
register : domain_key
become : no
run_once : true
- name : Generate domain.key
local_action : shell openssl genrsa 4096 > "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key"
become : no
run_once : true
when : domain_key.stat.exists == False
# generate domain csr, if necessary
- name : Find domain.csr
local_action :
module : stat
path : "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
register : domain_csr
become : no
run_once : true
- name : Generate domain.csr
local_action : shell openssl req -new -sha256 -key "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key" -subj "/CN={{ domain }}" > "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
become : no
run_once : true
when : domain_csr.stat.exists == False
- name : Create challenge
local_action :
module : letsencrypt
account_key_src : "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
csr : "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/signed.crt"
chain_dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/intermediate.pem"
fullchain_dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/combined.pem"
account_email : "{{ letsencrypt_account_email }}"
acme_directory : "{{ letsencrypt_acme_directory }}"
acme_version : "{{ letsencrypt_acme_version }}"
challenge : "{{ letsencrypt_challenge }}"
#agreement: "{{ letsencrypt_agreement }}"
remaining_days : "{{ letsencrypt_remaining_days }}"
become : no
run_once : true
register : create_challenge
- block :
- name : Create challenge file
local_action :
module : copy
content : "{{ create_challenge.challenge_data[domain]['http-01']['resource_value'] }}"
dest : "{{ playbook_dir }}/letsencrypt-challenges/{{ create_challenge.challenge_data[domain]['http-01']['resource'] | basename }}"
become : no
run_once : true
register : create_challenge_files
- name : Copy all challenge files to webserver
copy :
src : "{{ playbook_dir }}/letsencrypt-challenges/{{ challenge | basename }}"
dest : "/srv/www/{{ domain }}/.well-known/acme-challenge/{{ challenge | basename }}"
owner : www-data
group : www-data
mode : 0555
with_fileglob :
- "{{ playbook_dir }}/letsencrypt-challenges/*"
loop_control :
loop_var : challenge
register : challenges_copied
- name : Create certificate
local_action :
module : letsencrypt
account_key_src : "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
csr : "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/signed.crt"
chain_dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/intermediate.pem"
fullchain_dest : "{{ letsencrypt_cert_dir }}/{{ domain }}/combined.pem"
account_email : "{{ letsencrypt_account_email }}"
acme_directory : "{{ letsencrypt_acme_directory }}"
acme_version : "{{ letsencrypt_acme_version }}"
challenge : "{{ letsencrypt_challenge }}"
#agreement: "{{ letsencrypt_agreement }}"
remaining_days : "{{ letsencrypt_remaining_days }}"
data : "{{ create_challenge }}"
become : no
run_once : true
when : create_challenge is changed
register : verify_challenge
when : create_challenge is changed
- name : Copy all cert files to webserver
copy :
src : "{{ letsencrypt_cert_dir }}/{{ domain }}/{{ cert }}"
dest : "/etc/certs/{{ domain }}/{{ cert }}"
owner : root
group : root
mode : 0555
with_items :
- combined.pem
- domain.key
- intermediate.pem
- signed.crt
loop_control :
loop_var : cert
notify :
- reload apache2
- name : Delete old challenge file
file :
state : absent
path : "/srv/www/{{ domain }}/.well-known/acme-challenge/{{ create_challenge.challenge_data[domain]['http-01']['resource'] | basename }}"
The challenge file is created locally first (for debugging reasons), if that is not necessary a few more steps can be removed and the copy module can be used to create the challenge file directly on the remote host. Also if more than one hostname is specified, a loop over create_challenge.challenge_data
is necessary to create all challenge files. In the end, the certificate files are copied to /etc/certs/{{ domain }}
, but that might vary for your setup. Also the certificates are stored locally, and then copied to the webserver - in case the webserver must be setup again, a local copy of the certificates is available.