Actual Budget is a privacy-focused personal finance application that can be self-hosted. This guide explains how to install Actual Budget on a Debian 12 server using Docker, place it behind an existing Apache web server, and secure it with a Let’s Encrypt SSL certificate.
The resulting configuration looks like this:
Internet
|
+-- HTTP :80 --> Apache --> HTTPS redirect
|
+-- HTTPS :443
|
v
Apache 2.4
Let's Encrypt TLS
|
| HTTP over localhost
v
127.0.0.1:5006
|
v
Actual Budget Docker
The Docker container is deliberately bound only to 127.0.0.1. This prevents users on the Internet from connecting directly to Actual Budget on port 5006 and bypassing Apache.
In the examples below, replace:
actual.example.com with the hostname you intend to use.SERVER_IP with the IP address on which Apache is listening.For example:
actual.example.com
SERVER_IP
might become:
actual.yourdomain.com
192.0.2.10
Your DNS record for actual.example.com must point to the public IP address of the server.
Start by updating the system:
sudo apt update
sudo apt upgrade -y
Install Docker:
sudo apt install -y docker.io
Install Docker Compose support appropriate for your Debian 12 installation.
If the Docker Compose plugin is available:
sudo apt install -y docker-compose-plugin
You can check whether Compose is available with:
docker compose version
If your Debian repository configuration provides the older standalone Compose package instead, it can be installed with:
sudo apt install -y docker-compose
This guide uses the modern command syntax:
docker compose
Enable Docker at boot and start it:
sudo systemctl enable --now docker
Verify that Docker is running:
sudo systemctl status docker
You can also verify the installation with:
docker --version
docker compose version
Install Apache:
sudo apt install -y apache2
Enable Apache at boot and start it:
sudo systemctl enable --now apache2
Verify its status:
sudo systemctl status apache2
You can verify that Apache is listening on port 80 with:
ss -lntp | grep ':80'
Install Certbot and its Apache integration:
sudo apt install -y certbot python3-certbot-apache
Verify the installation:
certbot --version
Enable the Apache modules required for the reverse proxy:
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod proxy_wstunnel
sudo a2enmod headers
sudo a2enmod rewrite
sudo a2enmod ssl
These modules provide the following functionality:
proxy — Apache’s core proxy functionality.proxy_http — Allows Apache to proxy HTTP connections.proxy_wstunnel — Provides WebSocket proxy support.headers — Allows Apache to manipulate HTTP headers.rewrite — Provides URL rewriting support if needed.ssl — Enables HTTPS/TLS support.The Apache module is commonly referred to as mod_headers, but Debian’s a2enmod utility expects:
sudo a2enmod headers
You can verify the loaded modules with:
apache2ctl -M | grep -E 'proxy|headers|rewrite|ssl'
Test the Apache configuration:
sudo apache2ctl configtest
If everything is correct, Apache should report:
Syntax OK
Then restart Apache:
sudo systemctl restart apache2
Create a directory for Actual Budget and its persistent data:
sudo mkdir -p /opt/actual/data
cd /opt/actual
The resulting structure will eventually look similar to:
/opt/actual/
├── docker-compose.yml
└── data/
The data directory is important because it allows Actual Budget’s persistent data to survive container replacement or upgrades.
Create the Compose file:
sudo nano /opt/actual/docker-compose.yml
Add:
services:
actual:
image: actualbudget/actual-server:latest
container_name: actual_server
restart: unless-stopped
ports:
- "127.0.0.1:5006:5006"
volumes:
- ./data:/data
Save the file.
You may see examples using:
ports:
- "5006:5006"
That can expose port 5006 on every network interface.
Because Apache is going to be the public-facing server, there is no reason for clients on the Internet to communicate directly with the Actual Budget container.
Instead, use:
ports:
- "127.0.0.1:5006:5006"
The resulting path is:
Internet
|
v
Apache
|
v
127.0.0.1:5006
|
v
Actual Budget
Only applications running locally on the Debian server can connect directly to port 5006.
Start the container:
cd /opt/actual
sudo docker compose up -d
Verify that it is running:
sudo docker ps
You can also use:
sudo docker compose ps
Before configuring Apache as a reverse proxy, verify that Actual Budget itself works:
curl -v http://127.0.0.1:5006/
You should receive an HTTP response from Actual Budget.
You can also verify the listening socket:
ss -lntp | grep 5006
Ideally, port 5006 should be associated with:
127.0.0.1:5006
rather than:
0.0.0.0:5006
If curl http://127.0.0.1:5006/ does not work, fix the Docker or Actual Budget problem before troubleshooting Apache.
Create:
sudo nano /etc/apache2/sites-available/actual-budget.conf
For initial testing, use:
<VirtualHost SERVER_IP:80>
ServerName actual.example.com
DocumentRoot /var/www/actual-test
<Directory /var/www/actual-test>
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/actual_error.log
CustomLog ${APACHE_LOG_DIR}/actual_access.log combined
</VirtualHost>
Remember to replace SERVER_IP with the address Apache uses for its other virtual hosts.
For example:
<VirtualHost 192.0.2.10:80>
This is particularly important on Apache servers hosting multiple domains.
If your existing virtual hosts use explicit addresses such as:
<VirtualHost 192.0.2.10:80>
do not assume that creating the new site as:
<VirtualHost *:80>
will behave identically.
Keep the address/port binding strategy consistent with the server’s existing virtual hosts.
A symptom of an incorrect vhost match is requesting:
http://actual.example.com/
and receiving one of the server’s other websites instead.
When Apache cannot find the expected hostname within the applicable virtual-host set, another vhost may become the default for that IP address and port.
The command:
sudo apache2ctl -S
is extremely useful for diagnosing this situation.
Create the temporary document root:
sudo mkdir -p /var/www/actual-test
Create a test page:
echo '<h1>Apache is working for actual.example.com</h1>' | \
sudo tee /var/www/actual-test/index.html
Enable the site:
sudo a2ensite actual-budget.conf
Test the Apache configuration:
sudo apache2ctl configtest
If you receive:
Syntax OK
reload Apache:
sudo systemctl reload apache2
Now visit:
http://actual.example.com/
You should see:
Apache is working for actual.example.com
Before proceeding with SSL, check exactly how Apache sees the configuration:
sudo apache2ctl -S
Look for actual.example.com under the appropriate IP address and port 80.
If the browser displays a different website hosted on the same server, apache2ctl -S should be one of the first diagnostic commands you run.
You can also test the virtual host locally without relying on external DNS:
curl -H "Host: actual.example.com" http://127.0.0.1/
If Apache listens only on a specific local address rather than loopback, test that address instead.
Once the HTTP virtual host works correctly and public DNS for actual.example.com points to the server, request the certificate:
sudo certbot --apache -d actual.example.com
Certbot should obtain the certificate and configure Apache’s SSL support.
After Certbot finishes, run:
sudo apache2ctl configtest
You should receive:
Syntax OK
Once HTTPS is operational, the temporary test page is no longer necessary.
Change /etc/apache2/sites-available/actual-budget.conf to:
<VirtualHost SERVER_IP:80>
ServerName actual.example.com
ErrorLog ${APACHE_LOG_DIR}/actual_error.log
CustomLog ${APACHE_LOG_DIR}/actual_access.log combined
Redirect permanent / https://actual.example.com/
</VirtualHost>
Again, replace SERVER_IP with the appropriate server address.
Requests to:
http://actual.example.com/
will now be redirected to:
https://actual.example.com/
Certbot will normally create or modify an SSL virtual-host configuration.
Depending on the Certbot version and existing Apache configuration, you may see a file such as:
/etc/apache2/sites-available/actual-budget-le-ssl.conf
The HTTPS virtual host should contain the equivalent of:
<IfModule mod_ssl.c>
<VirtualHost SERVER_IP:443>
ServerName actual.example.com
ErrorLog ${APACHE_LOG_DIR}/actual_error.log
CustomLog ${APACHE_LOG_DIR}/actual_access.log combined
ProxyRequests Off
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:5006/
ProxyPassReverse / http://127.0.0.1:5006/
RequestHeader set X-Forwarded-Proto "https"
SSLCertificateFile /etc/letsencrypt/live/actual.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/actual.example.com/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>
Do not blindly replace certificate paths that Certbot has generated. If your Certbot-created configuration differs, retain the certificate paths it supplied.
Remove the temporary DocumentRoot and <Directory> configuration from the HTTPS vhost if Certbot copied them into it.
The completed request path is:
Browser
|
| HTTPS :443
v
Apache
|
| HTTP
v
127.0.0.1:5006
|
v
Actual Budget
Apache handles the public TLS connection.
Actual Budget receives an ordinary HTTP connection from Apache over the server’s loopback interface.
This directive:
ProxyPreserveHost On
preserves the original HTTP Host header.
This directive:
RequestHeader set X-Forwarded-Proto "https"
informs the backend that the original client connection used HTTPS even though Apache’s connection to the backend uses HTTP.
The latter directive requires the Apache headers module:
sudo a2enmod headers
Always test Apache before restarting or reloading it:
sudo apache2ctl configtest
The expected result is:
Syntax OK
Then reload:
sudo systemctl reload apache2
You can inspect all configured virtual hosts with:
sudo apache2ctl -S
Verify that actual.example.com appears on both port 80 and port 443 using the expected server IP address.
When troubleshooting a reverse proxy, testing each layer separately can save considerable time.
Run:
curl -v http://127.0.0.1:5006/
If this fails, the problem is probably Docker or Actual Budget rather than Apache.
Run:
curl -vk https://actual.example.com/
If the direct localhost test works but this fails, investigate Apache, the SSL configuration, or the reverse-proxy configuration.
Run:
curl -v http://actual.example.com/
You should receive a response similar to:
HTTP/1.1 301 Moved Permanently
Location: https://actual.example.com/
A useful troubleshooting sequence is therefore:
Does http://127.0.0.1:5006/ work?
|
+-- NO --> Docker / Actual Budget problem
|
+-- YES
|
v
Does https://actual.example.com/ work?
|
+-- NO --> Apache proxy / SSL problem
|
+-- YES
|
v
Does http://actual.example.com/ redirect?
|
+-- NO --> Port 80 virtual-host problem
|
+-- YES --> Configuration operational
Check running containers:
sudo docker ps
Check the Actual Budget container:
cd /opt/actual
sudo docker compose ps
View its logs:
sudo docker compose logs
Follow logs continuously:
sudo docker compose logs -f
Restart Actual Budget:
sudo docker compose restart
Stop the application:
sudo docker compose down
Start it again:
sudo docker compose up -d
Check Apache’s configuration syntax:
sudo apache2ctl configtest
Display Apache’s virtual-host mapping:
sudo apache2ctl -S
Display loaded modules:
sudo apache2ctl -M
Check service status:
sudo systemctl status apache2
Reload configuration:
sudo systemctl reload apache2
Restart Apache:
sudo systemctl restart apache2
View systemd logs:
sudo journalctl -u apache2
Follow the Actual Budget Apache logs:
sudo tail -f /var/log/apache2/actual_access.log
and:
sudo tail -f /var/log/apache2/actual_error.log
List installed certificates:
sudo certbot certificates
Test automatic certificate renewal:
sudo certbot renew --dry-run
On Debian 12, you can inspect Certbot’s renewal timer with:
systemctl status certbot.timer
Because Actual Budget is running in Docker, updating it is straightforward.
Change to the application directory:
cd /opt/actual
Download the current image:
sudo docker compose pull
Recreate the container using the new image:
sudo docker compose up -d
Check its status:
sudo docker compose ps
Then inspect the logs:
sudo docker compose logs --tail=100
Because the Actual Budget data resides in:
/opt/actual/data
rather than solely inside the container, recreating the container does not normally remove the persistent application data.
Backing up this directory before significant upgrades is nevertheless strongly recommended.
The completed Debian 12 installation consists of:
Debian 12
|
+-- Docker
| |
| +-- Actual Budget
| |
| +-- 127.0.0.1:5006
|
+-- Apache 2.4
|
+-- actual.example.com:80
| |
| +-- Redirect to HTTPS
|
+-- actual.example.com:443
|
+-- Let's Encrypt TLS
|
+-- Reverse proxy
|
+-- http://127.0.0.1:5006/
The important characteristics of this configuration are:
127.0.0.1.Run:
sudo apache2ctl -S
Check whether actual.example.com is associated with the correct IP address and port.
If your existing sites use:
<VirtualHost SERVER_IP:80>
and:
<VirtualHost SERVER_IP:443>
use the same binding convention for Actual Budget rather than mixing those virtual hosts with *:80 and *:443.
If:
sudo apache2ctl configtest
reports:
Invalid command 'RequestHeader', perhaps misspelled or defined by a module not included in the server configuration
enable the headers module:
sudo a2enmod headers
Then test again:
sudo apache2ctl configtest
Do not run:
sudo a2enmod mod_headers
On Debian, use:
sudo a2enmod headers
First test Actual Budget directly:
curl -v http://127.0.0.1:5006/
Then check:
sudo docker ps
and:
sudo docker compose logs
If the backend isn’t responding on 127.0.0.1:5006, Apache cannot proxy requests to it.
Before running Certbot, verify that:
http://actual.example.com/
reaches the correct Apache virtual host from the Internet.
Also verify that the domain’s DNS record points to the server and that inbound TCP port 80 is reachable.
Running Actual Budget behind Apache provides a clean way to integrate the application into an existing Debian 12 web server.
The most important design decision is keeping Actual Budget’s Docker port private:
127.0.0.1:5006
rather than exposing port 5006 publicly.
Apache remains responsible for public HTTP and HTTPS connections, Let’s Encrypt provides TLS certificates, and Actual Budget remains isolated behind the reverse proxy.
This arrangement works particularly well on servers that already host multiple Apache virtual hosts because Actual Budget can be added as another hostname without requiring an additional public web port.