Create a minimal Kubernetes charm

From Zero to Hero: Write your first Kubernetes charm > Create a minimal Kubernetes charm

See previous: Set up your development environment

As you already know from the Juju OLM, when you deploy a Kubernetes charm, the following things happen:

  1. The Juju controller provisions a pod with two containers, one for the Juju unit agent and the charm itself and one container for each application workload container that is specified in the containers field of a file in the charm that is called metadata.yaml.
  2. The same Juju controller injects Pebble – a lightweight, API-driven process supervisor – into each workload container and overrides the container entrypoint so that Pebble starts when the container is ready.
  3. When the Kubernetes API reports that a workload container is ready, the Juju controller informs the charm that the instance of Pebble in that container is ready. At that point, the charm knows that it can start communicating with Pebble.
  4. Typically, at this point the charm will make calls to Pebble so that Pebble can configure and start the workload and begin operations.

All subsequent workload management happens in the same way – the Juju controller sends events to the charm and the charm responds to these events by managing the workload application in various ways via Pebble. The picture below illustrates all of this for a simple case where there is just one workload container.

Refresh your memory: Juju OLM | Deployment on a Kubernetes pod

As a charm developer, your first job is to use this knowledge to create the basic structure and content for your charm:

  • descriptive files (e.g., YAML configuration files like the metadata.yaml file mentioned above) that give the Juju OLM, Python, or Charmcraft various bits of information about your charm, and
  • executable files (like the src/ file that we will see shortly) where you will use Ops-enriched Python to write all the logic of your charm.


  1. Set the basic information, requirements, and workload for your charm
  2. Define the charm initialisation and application services
  3. Add logger functionality
  4. Tell Charmcraft how to build your charm
  5. Validate your charm
  6. Review the final code

Set the basic information, requirements, and workload for your charm

Create a file called metadata.yaml. This is a file that describes metadata such as the charm name, purpose, environment constraints, workload containers, etc., in short, all the information that tells the Juju OLM what it can do with your charm.

Read more: File metadata.yaml

In this file, do all of the following:

First, add basic information about your charm:

name: demo-api-charm
display-name: |
description: |
  This is a demo charm built on top of a small Python FastAPI server.
  This charm could be related to PostgreSQL charm and COS Lite bundle (Canonical Observability Stack).
summary: |
  FastAPI Demo charm for Kubernetes

Second, add an environment constraint assuming the latest major Juju OLM version and a Kubernetes-type cloud:

  - juju >= 3.1
  - k8s-api

Read more: assumes

Third, describe the workload container, as below. Below, demo-server is the name of the container, and demo-server-image is the name of its OCI image.

    resource: demo-server-image

Read more: containers

Fourth, describe the workload container resources, as below. The name of the resource below, demo-server-image, is the one you defined above.

  # An OCI image resource for each container listed above.
  # You may remove this if your charm will run without a workload sidecar container.
    type: oci-image
    description: OCI image from GitHub Container Repository
    # The upstream-source field is ignored by Juju. It is included here as a reference
    # so the integration testing suite knows which image to deploy during testing. This field
    # is also used by the 'canonical/charming-actions' Github action for automated releasing.

Read more: resources

Define the charm initialisation and application services

Create a file called requirements.txt. This is a file that describes all the required external Python dependencies that will be used by your charm.

Read more: File requirements.txt

In this file, declare the ops dependency, as below. At this point you’re ready to start using constructs from the Ops library.

ops >= 2.0

Read more: Ops (ops)

Create a file called src/ This is the file that you will use to write all the Python code that you want your charm to execute in response to events it receives from the Juju controller.

Read more: src/

In this file, do all of the following:

First, add a shebang to ensure that the file is directly executable. Then, from the Ops library, import the CharmBase class and the main function. Then, use CharmBase to create a charm class FastAPIDemoCharm and then invoke this class in the main function of Ops. As you can see, a charm is a pure Python class that inherits from the CharmBase class of Ops and which we pass to the main function defined in the ops.main module.

#!/usr/bin/env python3
from ops.charm import CharmBase
from ops.main import main

class FastAPIDemoCharm(CharmBase):
    """Charm the service."""

    def __init__(self, *args):

if __name__ == "__main__":  # pragma: nocover

Read more: CharmBase

Now, in the __init__ function of your charm class, use Ops constructs to add an observer for when the Juju controller informs the charm that the Pebble in its workload container is up and running, as below. As you can see, the observer is a function that takes as an argument an event and an event handler. The event name is created automatically by Ops for each container on the template <container name>-pebble-ready. The event handler is a method in your charm class that will be executed when the event is fired; in this case, you will use it to tell Pebble how to start your application.

self.framework.observe(self.on.demo_server_pebble_ready, self._on_demo_server_pebble_ready)

Read more: <container name>-pebble-ready

Generally speaking: A charm class is a collection of event handling methods. When you want to install, remove, upgrade, configure, etc., an application, Juju sends information to your charm. Ops translates this information into events and your job is to write event handlers

Pro tip: Use __init__ to hold references (pointers) to other objects or immutable state only. That is because a charm is reinitialised on every event. See Talking to a workload: Control flow from A to Z.

Next, define the event handler, as follows:

First, import the ActiveStatus class. You can use this to set your charm status to active.

from ops.model import ActiveStatus

Second, use this as well as further Ops constructs to define the event handler, as below. As you can see, what is happening is that, from the event argument, you extract the workload container object in which you add a custom layer. Once the layer is set you replan your service and set the charm status to active.

def _on_demo_server_pebble_ready(self, event):
    """Define and start a workload using the Pebble API.

    Change this example to suit your needs. You'll need to specify the right entrypoint and
    environment configuration for your specific workload.

    Learn more about interacting with Pebble at at
    # Get a reference the container attribute on the PebbleReadyEvent
    container = event.workload
    # Add initial Pebble config layer using the Pebble API
    container.add_layer("fastapi_demo", self._pebble_layer, combine=True)
    # Make Pebble reevaluate its plan, ensuring any services are started if enabled.
    # Learn more about statuses in the SDK docs:
    self.unit.status = ActiveStatus()

The custom Pebble layer that you just added is defined in the self._pebble_layer property. Update this property to match your application, as follows:

First, at the beginning of your src/ file, import the Layer class:

from ops.pebble import Layer

Second, in the __init__ method of your charm class, name your service to fastapi-service and add it as a class attribute :

self.pebble_service_name = "fastapi-service"

Finally, define the pebble_layer function as below. The command variable represents a command line that should be executed in order to start our application.

def _pebble_layer(self):
    """Return a dictionary representing a Pebble layer."""
    command = " ".join(
    pebble_layer = {
        "summary": "FastAPI demo service",
        "description": "pebble config layer for FastAPI demo server",
        "services": {
            self.pebble_service_name: {
                "override": "replace",
                "summary": "fastapi demo",
                "command": command,
                "startup": "enabled",
    return Layer(pebble_layer)

Read more: How to configure the Pebble layer

Add logger functionality

In the src/ file, in the imports section, import the Python logging module and define a logger object, as below. This will allow you to read log data in juju.

import logging

# Log messages can be retrieved using juju debug-log
logger = logging.getLogger(__name__)

Tell Charmcraft how to build your charm

Create a file called charmcraft.yaml file. This is a file where you will describe all the information needed for Charmcraft to be able to pack your charm.

Read more: File charmcraft.yaml

In this file, do the following:

First, add the block below. This will identify your charm as a charm (as opposed to something else you might know from using the Juju OLM, namely, a bundle).

type: charm

Read more: type

Also add the block below. This declares that your charm will build and run charm on Ubuntu 22.04.

  - build-on:
    - name: ubuntu
      channel: "22.04"
    - name: ubuntu
      channel: "22.04"

Read more: bases

Aaaand that’s it! Time to validate your charm!

Pro tip: Once you’ve mastered the basics, you can speed things up by navigating to your empty charm project directory and running charmcraft init --profile kubernetes. This will create all the files above and more, along with helpful descriptor keys and code scaffolding.

Validate your charm

First, ensure that you are inside the Multipass Ubuntu VM, in the ~/fastapi-demo folder:

multipass shell charm-dev
cd ~/fastapi-demo

Now, pack your charm project directory into a .charm file, as below. This will produce a .charm file. In our case it was named demo-api-charm_ubuntu-22.04-amd64.charm; yours should be named similarly, though the name might vary slightly depending on your architecture.

charmcraft pack
# Packing the charm
# Created 'demo-api-charm_ubuntu-22.04-amd64.charm'.
# Charms packed:
#    demo-api-charm_ubuntu-22.04-amd64.charm 

Did you know? A .charm file is really just a zip file of your charm files and code dependencies that makes it more convenient to share, publish, and retrieve your charm contents.

Deploy the .charm file, as below. Juju will create a Kubernetes StatefulSet named after your application with one replica.

juju deploy ./demo-api-charm_ubuntu-22.04-amd64.charm --resource \

If you’ve never deployed a local charm (i.e., a charm from a location on your machine) before:
As you surely know, when you deploy a charm from Charmhub it is sufficient to run juju deploy <charm name>. However, to deploy a local charm you need to explicitly define a --resource parameter with the same resource name and resource upstream source as in the metadata.yaml.

Monitor your deployment:

juju status --watch 1s

When all units are settled down, you should see the output below, where is the IP of the K8s Service and is the IP of the pod.

Model        Controller           Cloud/Region        Version  SLA          Timestamp
charm-model  tutorial-controller  microk8s/localhost  3.0.0    unsupported  13:38:19+01:00

App             Version  Status  Scale  Charm           Channel  Rev  Address         Exposed  Message
demo-api-charm           active      1  demo-api-charm             1  no       

Unit               Workload  Agent  Address      Ports  Message
demo-api-charm/0*  active    idle  

Now, validate that the app is running and reachable by sending an HTTP request as below, where is the IP of our pod and 8000 is the default application port.


You should see a JSON string with the version of the application:


Expand if you wish to inspect your deployment further
  1. Run:
kubectl get namespaces

You should see that Juju has created a namespace called charm-model.

  1. Try:
kubectl -n charm-model get pods

You should see that your application has been deployed in a pod that has 2 containers running in it, one for the charm and one for the application. The containers talk to each other via the Pebble API using the UNIX socket.

NAME                             READY   STATUS    RESTARTS        AGE
modeloperator-5df6588d89-ghxtz   1/1     Running   3 (7d2h ago)    13d
demo-api-charm-0                 2/2     Running   0               7d2h
  1. Check also:
kubectl -n charm-model describe pod demo-api-charm-0

In the output you should see the definition for both containers. You’ll be able to verify that the default command and arguments for our application container (demo-server) have been displaced by the Pebble service. You should be able to verify the same for the charm container (charm).

Congratulations, you’ve successfully created a minimal Kubernetes charm!

Review the final code

For the full code see: 01_create_minimal_charm

For a comparative view of the code before and after our edits see: Comparison

See next: Make your charm configurable

Where is this created?

@erik-lonroth Please note that is a doc in a series; things make more sense when you’ve read the docs before. PS The docs are unlisted because still work in progress. Things should be clearer once they’re published.


@tmihoc I’m totally game. I’m writing a translation on this for LXD as we speak and testing it for tomorrows workshop.

How can I transfer the material to you?

Pro tip: Try to make your __init__ method as lightweight as possible. The reason is because a charm is a stateless application, so the class is reinitialised on every event, so a heavy init could result in high load calls.

Not sure what ‘high load calls’ means here, and the whole paragraph is a bit ambiguous. I think the ‘keep your __init__ light’ is a good principle, but I’d argue for it differently.

Maybe I would instead recommend: use __init__ to hold references (pointers) to other objects (relation wrappers for example) or immutable state only: mutable state is confusing in a charm’s context, because, as you say, a fresh instance will be created at each hook execution, so the state’s lifetime is bound to that of a single event handler (or several, if you factor deferrals in, but let’s say only one ‘juju event’).

I don’t think charm runtime (including __init__ runtime) is ever a realistic concern, given how little time charm execution takes compared to the broader Juju thing. So long as the charm takes under ~30 seconds to return, you’re fine.

1 Like

I had to use --force flag to make this work. Here is what I have in the logs

2023-02-23 19:44:19.698 :: 2023-02-24 02:44:18.921 - entrypoint: The entrypoint file is not executable: '/root/prime/src/' (
2023-02-23 19:44:19.698 :: 2023-02-24 02:44:18.921 Aborting due to lint errors (use --force to override).
2023-02-23 19:44:20.955 Failed to build charm for bases index '0'.
2023-02-23 19:44:20.963 Traceback (most recent call last):
2023-02-23 19:44:20.964   File "/snap/charmcraft/1171/lib/charmcraft/commands/", line 376, in pack_charm_in_instance
2023-02-23 19:44:20.964     instance.execute_run(cmd, check=True, cwd=instance_output_dir)
2023-02-23 19:44:20.964   File "/snap/charmcraft/1171/lib/craft_providers/lxd/", line 289, in execute_run
2023-02-23 19:44:20.964     return self.lxc.exec(
2023-02-23 19:44:20.964   File "/snap/charmcraft/1171/lib/craft_providers/lxd/", line 329, in exec
2023-02-23 19:44:20.964     return runner(final_cmd, **kwargs)  # pylint: disable=subprocess-run-check
2023-02-23 19:44:20.964   File "/snap/charmcraft/1171/usr/lib/python3.8/", line 516, in run
2023-02-23 19:44:20.964     raise CalledProcessError(retcode, process.args,
2023-02-23 19:44:20.964 subprocess.CalledProcessError: Command '['lxc', '--project', 'charmcraft', 'exec', 'local:charmcraft-demo-api-charm-258049-0-0-amd64', '--cwd', '/root/project', '--', 'env', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin', 'CHARMCRAFT_MANAGED_MODE=1', 'charmcraft', 'pack', '--bases-index', '0', '--verbosity=brief']' returned non-zero exit status 2.

it says that there are some linting issues

have you checked that code is fine?

Code as is from the repo. It’s complaining about the entry point not being executable and I am wondering how does it end up checking for that? through permissions?

maybe by shebang ?

do you have it included in your python file? also might worth to check permissions

Why are not use charmcraft init?

I had it added and I chmod u+x the file. This seems to resolve the issue.

1 Like

we had to revert this idea because it was too confusing for the tutorial

@sergiusens, does charmcraft require executable python files in order to pack it? or maybe the issue is somewhere else and we just see a side effect?

I have the same entrypoint not executable error as noted above. I’ve made the entrypoint executable in my local directory but that has not resolved anything for me:

rsyring@meld:~/projects/charm-caddy$ chmod u+x src/ 
rsyring@meld:~/projects/charm-caddy$ charmcraft pack
Packing the charm                                                                                                                                                                            
Lint Errors:                                                                                                                                                                                 
- entrypoint: The entrypoint file is not executable: '/root/prime/src/' (                              
Aborting due to lint errors (use --force to override).                                                                                                                                       
Failed to build charm for bases index '0'.                                                                                                                                                   

Anyone have further ideas?

I ended up finding the answer at: It has to do with the packer not detecting that the file’s permissions have changed.

Option #1

charmcraft clean
charmcraft pack  # Works

Option #2

$ touch src/

There is a minor typo in the doc having the word class twice in a row:

1 Like

@beliaev-maksim Since src/ file must be executable and contain shebang #!/usr/bin/env python3 in order to work I think this info needs to be explicitly added in to this tutorial. Based on my experience without setting to be executable charmcraft wan’t pack it and also if shebang is missing application does not start:

2023-06-12T14:13:59.097Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 1: from: not found 2023-06-12T14:13:59.098Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 2: from: not found 2023-06-12T14:13:59.100Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 3: from: not found 2023-06-12T14:13:59.100Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 4: from: not found 2023-06-12T14:13:59.100Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 5: import: not found 2023-06-12T14:13:59.100Z [container-agent] 2023-06-12 14:13:59 WARNING install ./src/ 8: Syntax error: “(” unexpected 2023-06-12T14:13:59.300Z [container-agent] 2023-06-12 14:13:59 ERROR juju.worker.uniter.operation runhook.go:167 hook “install” (via hook dispatching script: dispatch) failed: exit status 2 2023-06-12T14:13:59.301Z [container-agent] 2023-06-12 14:13:59 INFO juju.worker.uniter resolver.go:151 awaiting error resolution for “install” hook 2023-06-12T14:14:03.971Z [pebble] Check “readiness” failure 19 (threshold 3): received non-20x status code 418 2023-06-12T14:14:13.973Z [pebble] Check “readiness” failure 20 (threshold 3): received non-20x status code 418 2023-06-12T14:14:23.971Z [pebble] Check “readiness” failure 21 (threshold 3): received non-20x status code 418

1 Like

Thanks, will update! (Just ran into this issue myself earlier today.)

What I’m missing here is a) a definition of “local charm” and b) how to provide credentials for pulling a container image from a private registry.

On Kubernetes there is the concept of an ImagePullSecret to allow authenticating with a container registry. Is there a similar concept with charms, or what do you need to do to make your charm pull an image from a private registry?