# Uploading a theme via the admin API

**URL:** <https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249>\
**Category:** Themes\
**Created:** [February 24, 2020, 4:25pm UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249 "2020-02-24T16:25:55Z")\
**Posts on this page:** 6\
**Page:** 1

<div class="post-metadata">

**Author:** ![olliey](https://avatars.discourse-cdn.com/v4/letter/o/c68b51/32.png) [@olliey](https://forum.ghost.org/u/olliey)\
**Post date:** [February 24, 2020, 4:25pm UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/1 "2020-02-24T16:25:55Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![olliey](https://avatars.discourse-cdn.com/v4/letter/o/c68b51/32.png) [@olliey](https://forum.ghost.org/u/olliey)\
**Post date:** [February 24, 2020, 5:08pm UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/2 "2020-02-24T17:08:50Z")

</div>

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

POST  
https://{{SITE\_URL}}/ghost/api/v3/admin/themes/upload/  
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/

---

<div class="post-metadata">

**Author:** ![DavidDarnes](https://sea1.discourse-cdn.com/flex015/user_avatar/forum.ghost.org/daviddarnes/32/3533_2.png) [@DavidDarnes](https://forum.ghost.org/u/DavidDarnes)\
**Post date:** [February 25, 2020, 12:43pm UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/3 "2020-02-25T12:43:56Z")

</div>

Thanks for sharing your solution with the community @olliey

---

<div class="post-metadata">

**Author:** ![curiositry](https://sea1.discourse-cdn.com/flex015/user_avatar/forum.ghost.org/curiositry/32/1084_2.png) [@curiositry](https://forum.ghost.org/u/curiositry)\
**Post date:** [April 9, 2024, 5:48am UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/5 "2024-04-09T05:48:35Z")

</div>

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](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size) filesize limit; when I tested it with [https://www.endpoints.dev/](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/](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 (127.0.0.1) port 2368 (#0)`).

Thanks!

```auto
#!/bin/bash
set -x
set -e
THEME="golden-pro"
SITE_URL="http://localhost:2368"
KEY="[REDACTED]"
API_VERSION="v3.0"

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

# 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")

header_payload="${header_base64}.${payload_base64}"

# 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

TOKEN="${header_payload}.${signature}"

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

```

---

<div class="post-metadata">

**Author:** ![curiositry](https://sea1.discourse-cdn.com/flex015/user_avatar/forum.ghost.org/curiositry/32/1084_2.png) [@curiositry](https://forum.ghost.org/u/curiositry)\
**Post date:** [April 9, 2024, 7:47pm UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/6 "2024-04-09T19:47:52Z")

</div>

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:

```auto
# 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.

---

<div class="post-metadata">

**Author:** ![curiositry](https://sea1.discourse-cdn.com/flex015/user_avatar/forum.ghost.org/curiositry/32/1084_2.png) [@curiositry](https://forum.ghost.org/u/curiositry)\
**Post date:** [April 24, 2024, 1:05am UTC](https://forum.ghost.org/t/uploading-a-theme-via-the-admin-api/12249/7 "2024-04-24T01:05:41Z")

</div>

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

> **[How to Deploy a Ghost Theme using the Admin API + Bash + cURL](https://www.autodidacts.io/ghost-theme-upload-admin-api-bash-curl/)**
>
> Figuring out how to upload a Ghost theme using the Admin API from Bash shouldn't have required heroic effort. Embarrassingly, it did.
> 
> 
> ↓ Jump to the script ↓
> 
> 
> I cobbled a script together from the Ghost docs and the inevitable forum posts. However,...
