Unable to start service apache2: Job for apache2.service failed because the control process exited with error code - the three real causes and the Ansible playbook that shows you which one (reproduced on Ubuntu 24.04)
TL;DR
- The message is a relay, not a reason. Ansible's
servicemodule ransystemctl start apache2, systemd ranapachectl start, apachectl exited non-zero. The reason is injournalctl -u apache2 -n 20on the target. - Three causes cover almost every case: something else on port 80 (
AH00072: make_sock: could not bind to address 0.0.0.0:80), a syntax error in a config file (AH00526: Syntax error on line 1 of ...: Invalid command 'Lisen'), and a directive from a module that is not enabled (Invalid command 'SSLEngine', fix witha2enmod ssl). apache2ctl configtestfinds the second and third in 18 ms without touching the running service. Run it as a task beforeservice: state=started.- The playbook pattern: a
blockwith configtest thenservice, and arescuethat prints the last 15 journal lines, so the failure message tells you what happened instead of sending you to the server. - Every output below is real, from ansible-core 2.21.5 against an Ubuntu 24.04 container with Apache 2.4.58 on 2026-10-16.
- Not your exact case? The closest post on this site is Ansible Handlers Explained.
The 2023 version of this post listed things to check. This time I broke Apache three ways on purpose, ran the same playbook against each, and kept the output, so you can match your journal lines to one of the three.
Table of Content
- What the error actually says
- The playbook that fails, and the one that diagnoses
- Cause 1 - port 80 is already taken
- Cause 2 - a typo in a config file
- Cause 3 - a directive from a module that is not enabled
- The fix in the playbook - validate, start, show the journal on failure
- What broke when I ran this
- Conclusion
1. What the error actually says
1fatal: [web]: FAILED! => {"changed": false, "msg": "Unable to start service apache2: Job for apache2.service failed because the control process exited with error code.\nSee \"systemctl status apache2.service\" and \"journalctl -xeu apache2.service\" for details.\n"}
Three layers produced that line, and only the bottom one knows why:
| Layer | What it did | What it tells you |
|---|---|---|
Ansible ansible.builtin.service | ran systemctl start apache2, got exit code 1 | "Unable to start service apache2" |
| systemd | ran the unit's ExecStart=apachectl start, the control process exited 1 | "Job for apache2.service failed because the control process exited with error code" |
| apachectl / apache2 | tried to parse the config and bind the ports | the real reason, in the journal: AH00072, AH00526, ... |
So the first move is always the same, on the target or through Ansible: journalctl -u apache2 --no-pager -n 20. The lines that start with apachectl[...] are Apache's own; the AH codes are indexed in the Apache documentation and a search for the code finds the cause faster than the message text.

2. The playbook that fails, and the one that diagnoses
The playbook everyone has:
1# start.yml
2- hosts: web
3 tasks:
4 - name: Ensure Apache is running
5 ansible.builtin.service:
6 name: apache2
7 state: started
And a second one I keep next to it, which runs the three checks that find all three causes:
1# diagnose.yml
2- hosts: web
3 tasks:
4 - name: Syntax check
5 ansible.builtin.command: apache2ctl configtest
6 register: ct
7 ignore_errors: true
8 - debug: var=ct.stderr_lines
9
10 - name: Last journal lines
11 ansible.builtin.command: journalctl -u apache2 --no-pager -n 8
12 register: jl
13 - debug: var=jl.stdout_lines
14
15 - name: What is on port 80
16 ansible.builtin.shell: ss -ltnp | grep ':80 ' || true
17 register: ss
18 - debug: var=ss.stdout_lines
On a healthy target the first playbook does what it should (ansible-core 2.21.5, Apache 2.4.58):
1$ ansible-playbook -i inventory.ini start.yml
2
3TASK [Ensure Apache is running] ************************************************
4changed: [web]
5
6PLAY RECAP *********************************************************************
7web : ok=2 changed=1 unreachable=0 failed=0
The next three sections break the target and run both playbooks. The service module output is identical in all three cases, which is the whole point: you cannot tell them apart from Ansible's message.
3. Cause 1 - port 80 is already taken
I stopped Apache and started nginx, the most common real-world version of this (a second web server, a stale Apache process, a container publishing port 80):
1$ ansible-playbook -i inventory.ini start.yml
2
3TASK [Ensure Apache is running] ************************************************
4fatal: [web]: FAILED! => {"changed": false, "msg": "Unable to start service apache2: Job for apache2.service failed because the control process exited with error code.\nSee \"systemctl status apache2.service\" and \"journalctl -xeu apache2.service\" for details.\n"}
5
6$ ansible-playbook -i inventory.ini diagnose.yml
7
8TASK [debug] *******************************************************************
9 "ct.stderr_lines": [
10 "AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2. Set the 'ServerName' directive globally to suppress this message",
11 "Syntax OK"
12 ]
13 "jl.stdout_lines": [
14 "systemd[1]: Starting apache2.service - The Apache HTTP Server...",
15 "apachectl[1459]: (98)Address already in use: AH00072: make_sock: could not bind to address 0.0.0.0:80",
16 "apachectl[1459]: no listening sockets available, shutting down",
17 "apachectl[1459]: AH00015: Unable to open logs",
18 "systemd[1]: apache2.service: Control process exited, code=exited, status=1/FAILURE",
19 "systemd[1]: Failed to start apache2.service - The Apache HTTP Server."
20 ]
21 "ss.stdout_lines": [
22 "LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:((\"nginx\",pid=1329,fd=5),(\"nginx\",pid=1328,fd=5),(\"nginx\",pid=1327,fd=5))",
23 "LISTEN 0 511 [::]:80 [::]:* users:((\"nginx\",pid=1329,fd=6),(\"nginx\",pid=1328,fd=6),(\"nginx\",pid=1327,fd=6))"
24 ]
Syntax OK plus AH00072: make_sock: could not bind to address 0.0.0.0:80 is this cause, and ss -ltnp names the owner: nginx, PIDs 1327 to 1329. AH00015: Unable to open logs below it is a consequence, not a second problem. The fix is one of: stop and disable the other service (systemctl disable --now nginx), or change Apache's port in /etc/apache2/ports.conf (the Listen directive). The AH00558 line about ServerName is a warning that appears on every run and is not the cause; set ServerName localhost in apache2.conf to silence it. ss is the modern replacement for the netstat I used in 2023 and is documented in ss(8).

4. Cause 2 - a typo in a config file
One misspelled directive in any enabled file (Lisen 8080 in /etc/apache2/conf-enabled/typo.conf):
1$ ansible-playbook -i inventory.ini diagnose.yml
2
3TASK [Syntax check] ************************************************************
4fatal: [web]: FAILED! => {"changed": true, "cmd": ["apache2ctl", "configtest"], "delta": "0:00:00.018219", "rc": 1, "stderr_lines": ["AH00526: Syntax error on line 1 of /etc/apache2/conf-enabled/typo.conf:", "Invalid command 'Lisen', perhaps misspelled or defined by a module not included in the server configuration"]}
5...ignoring
6
7 "jl.stdout_lines": [
8 "systemd[1]: Starting apache2.service - The Apache HTTP Server...",
9 "apachectl[1969]: AH00526: Syntax error on line 1 of /etc/apache2/conf-enabled/typo.conf:",
10 "apachectl[1969]: Invalid command 'Lisen', perhaps misspelled or defined by a module not included in the server configuration",
11 "systemd[1]: apache2.service: Control process exited, code=exited, status=1/FAILURE"
12 ]
13 "ss.stdout_lines": []
AH00526 gives the file and the line number; the journal and configtest say exactly the same thing, but configtest said it in 18 ms (delta 0:00:00.018219) without a start attempt. Fix the line, run apache2ctl configtest until it prints Syntax OK, then start. If the file was written by an Ansible template task, the typo is in your template; add validate: 'apache2ctl -t -f %s' to that task so the typo fails the template step rather than the service step.

5. Cause 3 - a directive from a module that is not enabled
The same AH00526, but the directive is spelled correctly. A virtual host with SSLEngine on on a server where mod_ssl is not enabled:
1 "ct.stderr_lines": [
2 "AH00526: Syntax error on line 2 of /etc/apache2/sites-enabled/bad.conf:",
3 "Invalid command 'SSLEngine', perhaps misspelled or defined by a module not included in the server configuration"
4 ]
The second half of the message, defined by a module not included in the server configuration, is the clue. SSLEngine needs mod_ssl, ProxyPass needs mod_proxy, RewriteEngine needs mod_rewrite, Header needs mod_headers. The fix is a2enmod ssl (or the module in question) before the vhost is enabled, and in Ansible that is a task with community.general.apache2_module or simply command: a2enmod ssl with creates: /etc/apache2/mods-enabled/ssl.load, placed before the template task that writes the vhost.
6. The fix in the playbook - validate, start, show the journal on failure
Putting it together: validate the configuration first, then start, and if either fails, print the journal before failing the play. This is block and rescue:
1# safe.yml
2- hosts: web
3 tasks:
4 - block:
5 - name: Validate the Apache configuration first
6 ansible.builtin.command: apache2ctl configtest
7 changed_when: false
8
9 - name: Ensure Apache is running and enabled
10 ansible.builtin.service:
11 name: apache2
12 state: started
13 enabled: true
14
15 rescue:
16 - name: Show why it failed
17 ansible.builtin.command: journalctl -u apache2 --no-pager -n 15
18 register: jl
19 - debug: var=jl.stdout_lines
20 - fail: msg="Apache could not start - see the journal above"
With the typo back in place:
1$ ansible-playbook -i inventory.ini safe.yml
2
3TASK [Validate the Apache configuration first] *********************************
4fatal: [web]: FAILED! => {"changed": false, "cmd": ["apache2ctl", "configtest"], "delta": "0:00:00.019274", "rc": 1, "stderr_lines": ["AH00526: Syntax error on line 1 of /etc/apache2/conf-enabled/typo.conf:", "Invalid command 'Lisen', perhaps misspelled or defined by a module not included in the server configuration"]}
5
6TASK [Show why it failed] ******************************************************
7changed: [web]
8
9TASK [debug] *******************************************************************
10 "jl.stdout_lines": [
11 "apachectl[2436]: AH00526: Syntax error on line 2 of /etc/apache2/sites-enabled/bad.conf:",
12 "apachectl[2436]: Invalid command 'SSLEngine', perhaps misspelled or defined by a module not included in the server configuration",
13 "systemd[1]: Failed to start apache2.service - The Apache HTTP Server.",
14 "systemd[1]: Started apache2.service - The Apache HTTP Server."
15 ]
16
17TASK [fail] ********************************************************************
18fatal: [web]: FAILED! => {"changed": false, "msg": "Apache could not start - see the journal above"}
19
20PLAY RECAP *********************************************************************
21web : ok=3 changed=1 unreachable=0 failed=1 skipped=0 rescued=1 ignored=0
22
23$ ansible-playbook -i inventory.ini safe.yml # after removing typo.conf
24TASK [Validate the Apache configuration first] *********************************
25ok: [web]
26TASK [Ensure Apache is running and enabled] ************************************
27ok: [web]
28PLAY RECAP *********************************************************************
29web : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0
The configtest task failed in 19 ms, the service was never touched, and the play's own output contains the file and line to fix. changed_when: false keeps the validation from reporting changed on every run, and enabled: true makes the service come back after a reboot, which the original playbook forgot. For the restart-on-config-change half of this, Ansible handlers is the pattern, and journalctl options such as --since "10 minutes ago" are in journalctl(1).
7. What broke when I ran this
I ran this in an Ubuntu 24.04 systemd container (jrei/systemd-ubuntu:24.04, --privileged, cgroup v2 mounted) on a GitHub Actions runner on 2026-10-16, with Ansible connecting through the community.docker.docker connection plugin, the same setup as my Ansible lab on a laptop with Docker.
1. The first version of my block/rescue playbook did not parse. I generated it with a sed that re-indented the tasks and got YAML parsing failed: While parsing a block collection did not find expected '-' indicator at the register: jl line, which had ended up two spaces short. rescue: must be at the same indentation as block:, and every task under both must be indented consistently. I rewrote the file by hand (the version above) and re-ran; ansible-core 2.21 points at the exact line and column, which made it a 2-minute fix.
2. The journal in the rescue showed the previous failure too. In the output above the last 15 lines include the SSLEngine failure from cause 3 and a Started apache2.service line, because the journal is a history, not a status. Read the journal from the bottom up to the most recent Starting apache2.service line; everything above it is older.
3. The container needed 6 seconds before systemd would answer. systemctl immediately after docker run fails with a connection error; the script sleeps 6 seconds. On a freshly booted VM the equivalent is a wait_for_connection task before anything touches services.
4. AH00558 appears on every single start and means nothing here. The ServerName warning was in all three failures and in the healthy start. If your journal has only AH00558 and status=1/FAILURE, scroll further; the cause is on another line.
8. Conclusion
To summarise:
- "Unable to start service apache2 ... control process exited with error code" is three layers of relay; the cause is in
journalctl -u apache2. AH00072 ... could not bind to address 0.0.0.0:80is port 80 taken,ss -ltnpnames the owner.AH00526 ... Invalid commandis a typo or a module that is not enabled;apache2ctl configtestfinds it in 18 ms.- Run configtest as a task before the service task, inside a
block, with arescuethat prints the journal. - Add
enabled: trueso Apache survives the next reboot.
If Apache is not even installed yet, the Ansible installation guide and the Terraform and Ansible production web app cover the install-configure-start sequence end to end.
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.