Andreas Kröpelin


Deploying locally with Documenter.jl

September 23, 2025

When creating documentation for a Julia package, it is the de-facto standard to use Documenter.jl. For building the static HTML pages, it offers two main functions: makedocs and deploydocs. While you can execute makedocs on your local machine without problems, the same does not hold for deploydocs as it pushes the built pages to some remote git repository (e.g. on GitHub). When you want to handle deploying your documentation more manually without a hosting service like GitHub pages, this becomes a nuisance since deploydocs is also responsible for the version selection interface. So we want to call it but not with the predefined configuration.

Here, I would like to demonstrate how you can trick Documenter into deploying your documentation locally.

The strategy

We will make use of the fact that git does not care at all where a remote repository is located. It can be somewhere on the network across the globe or it can be right on the same disk. Therefore, we will create a local remote repository .pseudo_remote inside your docs folder, direct Documenter to deploy into this repository, and finally clone/pull from it into a public folder, which you can then put on your static hosting service or whatever.

The execution

Inside your docs folder, where you probably also have a make.jl file, create a new deploy.jl file. In it, we first import the Documenter module:

using Documenter

Next, we make sure that the .pseudo_remote respository exists. If the directory does not exist yet, we create it and initialize a bare git repository. It is important that it is a bare repository, otherwise it cannot function as a remote. Documenter uses git via Documenter.git(), so let us do the same.

pseudo_remote = abspath(".pseudo_remote")
if !isdir(pseudo_remote)
    mkdir(pseudo_remote)
    cd(pseudo_remote) do
        run(`$(Documenter.git()) init --bare`)
    end
end

Now, we get to the interesting part. Documenter has an API it calls deployment systems. A deployment system determines if and how and where documentation is deployed, so that is exactly what we need. To create our own such system, we need to subtype DeployConfig:

struct LocalDeploy <: Documenter.DeployConfig end

The first thing LocalDeploy has to decide is if the docs should be deployed at all by adding a method to Documenter.deploy_folder that returns a DeployDecision. This is relevant for CI systems where you might want to deploy only under certain conditions. We will just manually trigger deployment so we can simply always decide to deploy by setting the all_ok keyword to true. Some of the other keyword arguments we just propagate.

Documenter.deploy_folder(::LocalDeploy; repo, branch, kwargs...) =
    Documenter.DeployDecision(; all_ok = true, branch, repo, subfolder = "main")

Documenter also wants to know where it can find the remote repository and what protocol it is supposed to use for pushing (HTTPS or SSH). We tell it to use HTTPS since this requires no concept of usernames or keys that we would not have in our local-only setting. And where to find the remote repository? Well, that is just our local folder .pseudo_remote!

Documenter.authentication_method(::LocalDeploy) = Documenter.HTTPS
Documenter.authenticated_repo_url(::LocalDeploy) = pseudo_remote

And with that we are ready to deploy. We call the deploydocs function and tell it to deploy to our pseudo remote repository and use LocalDeploy() as configuration. This also assumes that we want to deploy the docs from the main branch.

deploydocs(;
    repo = pseudo_remote,
    branch = "main",
    deploy_config = LocalDeploy(),
    devurl = "main",
)

The remote repository now contains the deployed HTML pages. However, it is the nature of bare git repositories that we cannot directly access the files. Instead, we must clone it or, if we already did that before, pull from it. We use the local folder public as the target for this operation:

deploy_dir = "public"
if isdir(deploy_dir)
    cd(deploy_dir) do
        run(`$(Documenter.git()) pull`)
    end
else
    run(`$(Documenter.git()) clone $pseudo_remote $deploy_dir`)
end

What you now want to do with the files in public is up to you. If, for example, you want to publish them to your static web hosting service, you could copy them there via scp:

items = filter(!endswith(".git"), readdir(deploy_dir; join = true))
run(`scp -r $items user@your.domain:path/to/Package.jl`)

From your package's root directory you can then run

julia --project=docs make.jl
julia --project=docs deploy.jl

to deploy your documentation.

Summary of deploy.jl

using Documenter

pseudo_remote = abspath(".pseudo_remote")
if !isdir(pseudo_remote)
    mkdir(pseudo_remote)
    cd(pseudo_remote) do
        run(`$(Documenter.git()) init --bare`)
    end
end

struct LocalDeploy <: Documenter.DeployConfig end
Documenter.deploy_folder(::LocalDeploy; repo, branch, kwargs...) =
    Documenter.DeployDecision(; all_ok = true, branch, repo, subfolder = "main")
Documenter.authentication_method(::LocalDeploy) = Documenter.HTTPS
Documenter.authenticated_repo_url(::LocalDeploy) = pseudo_remote

deploydocs(;
    repo = pseudo_remote,
    branch = "main",
    deploy_config = LocalDeploy(),
    devurl = "main",
)

deploy_dir = "public"
if isdir(deploy_dir)
    cd(deploy_dir) do
        run(`$(Documenter.git()) pull`)
    end
else
    run(`$(Documenter.git()) clone $pseudo_remote $deploy_dir`)
end

# only if you want to directly put the docs on a server
items = filter(!endswith(".git"), readdir(deploy_dir; join = true))
run(`scp -r $items user@your.domain:path/to/Package.jl`)