Uploading a theme via the admin API

I want to use the admin API to upload and then activate a theme using bash. The idea is that I’ll create a file in same directory as my bash script which will be uploaded every time the script is run.

I’m pretty sure this is possible, as I can see that /themes/ has an upload method, but there is no documentation around it.

What should the request object look like?

Many thanks

1 Like

I figured it out, but for anyone looking at this:

form data
I specified the path to my file

Using cURL:

curl -X POST -H “Authorization: Ghost ${TOKEN}” -F “file=@./path/to/file.zip;type=application/zip” https://{{SITE_URL}}/ghost/api/v3/admin/themes/upload/

1 Like

Thanks for sharing your solution with the community @olliey

Thanks for this @oilly. Is the snippet still working for you?

I’m getting HTTP/1.1 400 Bad Request errors with my bash script, which I cobbled together using the bash JWT example in the docs, your post, and the theme upload info in the docs.

I have installed newer versions of CURL (7.81.0 was throwing an error that was fixed in v8.x), different Ghost API versions (the docs reference v3, but I also tried v5.0 since I didn’t actually find anything clearly stating what the current API version is.)

For a while I thought I might be hitting the nginx client_max_body_size filesize limit; when I tested it with https://www.endpoints.dev/, it failed with 413 Payload Too Large status code, but worked if I deleted files out of the theme zip until it was just a few KB.

However, with Ghost it fails even with everything but package.json removed from the zip. I tried editing some Ghost core files (server/web/api/endpoints/admin/app.js) to increase the limit from 50mb to 500mb (don’t know if it worked).

https://jwt.io/ seems to think my token is valid.

Any insight into what stupid mistake I have made, from someone who is able to successfully upload a theme with bash, would be very much appreciated!

The weird thing is that there are no errors, but also no response from Ghost; nothing shows up in Ghost’s logs, even though curl says it connected (Connected to localhost ( port 2368 (#0)).


set -x
set -e

# Split the key into ID and SECRET
IFS=':' read ID SECRET <<< "$KEY"

# Prepare header and payload
NOW=$(date +'%s')
FIVE_MINS=$(($NOW + 300))
HEADER="{\"alg\": \"HS256\",\"typ\": \"JWT\", \"kid\": \"$ID\"}"
PAYLOAD="{\"iat\":$NOW,\"exp\":$FIVE_MINS,\"aud\": \"/admin/\"}"

# Helper function for performing base64 URL encoding
base64_url_encode() {
    declare input=${1:-$(</dev/stdin)}
    # Use `tr` to URL encode the output from base64.
    printf '%s' "${input}" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_'

# Prepare the token body
header_base64=$(base64_url_encode "$HEADER")
payload_base64=$(base64_url_encode "$PAYLOAD")


# Create the signature
signature=$(printf '%s' "${header_payload}" | openssl dgst -binary -sha256 -mac HMAC -macopt hexkey:$SECRET | base64_url_encode)

# Concat payload and signature into a valid JWT token


zip -r "$(date -I)-${THEME}.zip" "$THEME" -x '*git*' '*node_modules*' '*bower_components*'

curl --verbose -X POST -H "Authorization: Ghost ${TOKEN}" -H "Accept-Version: $API_VERSION" -F "file=@/var/www/ghost/content/themes/$(date -I)-${THEME}.zip" $SITE_URL/ghost/api/admin/themes/upload

For any one else with the same problem, I have figured out what it was.

Inspecting the output of each step revealed that the bash cURL example in the docs ends up with a newline in the JWT (which I missed because it matched my terminal width).

It was turning up in the base64 encoded header payload. Here is an updated base64 function with | tr -d '\n' to strip newlines, which made it work:

# Helper function for performing base64 URL encoding
base64_url_encode() {
    declare input=${1:-$(</dev/stdin)}
    # Use `tr` to URL encode the output from base64.
    printf '%s' "${input}" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_' | tr -d '\n' 

I will clean-up my whole theme upload and activation script and post it somewhere shortly.

1 Like

Here is my script, which also adds a nice FZF fuzzy picker for choosing which theme to upload to which site: