Self-Hosting Actual Budget on Debian 12 with Docker and Apache

Installing Actual Budget on Debian 12 with Docker, Apache, and Let’s Encrypt

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.

1. Update Debian 12

Start by updating the system:

sudo apt update
sudo apt upgrade -y

2. Install Docker and Docker Compose

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

3. Install Apache

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'

4. Install Certbot

Install Certbot and its Apache integration:

sudo apt install -y certbot python3-certbot-apache

Verify the installation:

certbot --version

5. Enable the Required Apache Modules

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.

A Debian-specific note about mod_headers

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

6. Create the Actual Budget Directory

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.

7. Create the Docker Compose Configuration

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.

Why Bind Port 5006 to 127.0.0.1?

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.

8. Start Actual Budget

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.

9. Create an Apache HTTP Virtual Host

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>

Why the VirtualHost IP Matters

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.

10. Create a Temporary Test Page

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

11. Verify Apache’s Virtual Host Selection

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.

12. Obtain the Let’s Encrypt Certificate

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

13. Change HTTP to an HTTPS Redirect

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/

14. Configure the HTTPS Reverse Proxy

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.

15. Understanding the Reverse Proxy

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

16. Test the Apache Configuration

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.

17. Test Each Layer Separately

When troubleshooting a reverse proxy, testing each layer separately can save considerable time.

Test Actual Budget Directly

Run:

curl -v http://127.0.0.1:5006/

If this fails, the problem is probably Docker or Actual Budget rather than Apache.

Test HTTPS Through 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.

Test the HTTP Redirect

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

18. Useful Docker Commands

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

19. Useful Apache Commands

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

20. Useful Certbot Commands

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

21. Updating Actual Budget

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.

22. Final Configuration

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:

  • Actual Budget runs independently inside Docker.
  • Persistent data is stored outside the container.
  • Port 5006 is bound only to 127.0.0.1.
  • Apache is the only public-facing web service.
  • HTTP requests are redirected to HTTPS.
  • Apache terminates the TLS connection.
  • Let’s Encrypt provides the SSL certificate.
  • Apache proxies requests internally to Actual Budget.
  • The Docker service automatically restarts unless explicitly stopped.
  • Apache and Docker are configured to start automatically with Debian.

23. Common Problems

Apache Displays Another Hosted Website

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.

Apache Reports “Invalid command ‘RequestHeader'”

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

a2enmod Says mod_headers Does Not Exist

Do not run:

sudo a2enmod mod_headers

On Debian, use:

sudo a2enmod headers

Apache Returns 502 Bad Gateway

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.

Certbot Cannot Validate the Domain

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.

Conclusion

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.

About the Author

Jim Lucas

Owner and proprietor of this establishment

Leave a Reply

Your email address will not be published. Required fields are marked *