We use cookies to enhance your experience on the site
CodeWorlds

Cloud - deployment

The Safari API is finished. The pytest suite is green, Ruff has nothing to say about the style, mypy did not find a single mislabelled species, the Docker image builds in a minute, and the pipeline in GitHub Actions lets every commit through to the main branch. You start the container, open the local address in a browser, and the species catalogue answers exactly the way it should. Then you close the laptop and go to sleep.

In that same second your application stops existing for the rest of the world. The ranger from the northern sector who meant to type in a giraffe sighting from her phone gets a message about a failed connection. The university biologist you promised access to the catalogue sees an empty page. Nobody apart from you has ever touched this application, because it only ever ran on your desk, and your desk has no public address and does not work at night.

That is precisely the difference between code that runs and a service that lives. A service needs a machine switched on twenty four hours a day, a public address, an HTTPS certificate, an automatic restart after a crash, a way to upload a new version without switching off the old one, and a safe place to keep passwords. None of that can be written in Python. It has to be bought, and that is exactly what the cloud sells. The only question you have to answer today is how much of that list you want to handle with your own hands.

Three levels of running a camp

Imagine you have to set up a research camp in the bush. There are exactly three ways to do it, and they differ in how much of the work you take on yourself.

The first: you buy a piece of land. You dig the well yourself, you run the power from your own generator, you pitch the tents, you hire the night guard and you repair the generator yourself when it dies at three in the morning. You have full control over every detail and full responsibility for every breakdown. In the cloud this corresponds to IaaS, that is Infrastructure as a Service. The provider gives you a bare virtual machine with a Linux system on it, and there its role ends. Everything else - the Python interpreter, the proxy server, the certificate, the automatic restart, the security updates - you install and watch over yourself. That is how AWS EC2, Google Compute Engine and DigitalOcean Droplets work.

The second way: you arrive at a finished lodge on the edge of the reserve. Somebody else keeps the power, the water, the security and the access road going. You bring your equipment, you unpack and you start work the same afternoon. In the cloud this is PaaS, that is Platform as a Service. You hand the platform your repository and it works out for itself that this is Python, installs the dependencies, builds the application, starts it, assigns a public address and issues an HTTPS certificate. That is how Heroku, Railway, Render and Fly.io work.

The third way: you do not keep a camp at all. You hire a guide for a single trip and you pay for the hours the two of you actually spent in the field. Between expeditions you pay nothing, because nothing is standing there and nothing is drawing power. This is serverless: you upload a single function and the provider runs it only when a request arrives, billing you for the number of invocations. That is how AWS Lambda, Google Cloud Functions and Vercel work, the last being where a frontend most often lands. The name is slightly dishonest here: the servers obviously exist, they merely stop being your problem.

The abbreviation PaaS is worth taking apart into its pieces, because it is one of those terms everyone repeats and almost nobody expands. You read it letter by letter: Platform as a Service. The first letter stands for platform and for nothing else. It is not "Process as a Service", because PaaS does not sell you a single process, it sells you a whole runtime environment together with networking, a certificate and a restart after a crash. It is not "Programming as a Service" either, because the programming stays entirely on your side - the platform will not write a single line for you and knows nothing about your domain. And it is certainly not "Python as a Service": on the same platform you will deploy an application written in Node.js, Go, Ruby or Java exactly the way you deploy a Python one, and the fact that the first letter matches the name of our language is pure coincidence. The definition worth memorising fits into one sentence: PaaS is a platform that manages the infrastructure for you. The same family of abbreviations has two more relatives: IaaS, which you met a moment ago, and SaaS (Software as a Service) - a finished program in a browser, such as mail or a spreadsheet, where you deploy nothing at all, because you are an ordinary user.

My recommendation for a first deployment is unambiguous: choose PaaS. Not because IaaS is bad, but because a first deployment should teach you one new thing - how code gets to production - and not five at once. Starting from a bare machine, you also learn Linux administration, proxy server configuration, certificate management, system services and firewall rules along the way. Every one of those is interesting and every one of them will eat an evening before you see your application at a public address. On a PaaS you will see it in a quarter of an hour, and you will reach for IaaS when the platform starts to constrain you - and by then you will know exactly what you are missing.

Two files that tell the platform what to do

Let us agree that you picked Railway, a modern PaaS with a generous free tier. You connect the repository, and at that moment a question appears that has to be asked out loud: how is the platform supposed to know what to do with this? It receives a directory full of files. It does not know whether this is a library, a script run once a day, or an HTTP server. It does not know which file is the entry point. It does not know which port to listen on.

The answer is simple: you have to write it down for it, in files that sit in the repository next to the code and are versioned along with it. So let us look at what a complete project ready for deployment looks like. There is nothing magical here - it is exactly the same layout you know from the lessons on testing and Docker, enlarged by a few new files.

1safari-api/
2    src/
3        __init__.py
4        main.py
5    tests/
6        test_species.py
7    requirements.txt
8    Dockerfile
9    .dockerignore
10    Procfile
11    railway.json
12    .env
13    .env.example
14    .gitignore

Pay attention to what is not here: not a single file you had before has disappeared. The code still sits in

src/
, the tests in
tests/
, the dependency list in
requirements.txt
, and
Dockerfile
and
.dockerignore
stay untouched where they were. Deployment does not rebuild the project, it merely adds a few sheets of instructions to it. Exactly two things are new: the pair
Procfile
and
railway.json
, which we get to in a moment, and the pair
.env
and
.env.example
, which we deal with in the second half of the lesson. The
.env
file appears here only because it physically lies in your directory - it will never reach the repository, because
.gitignore
sees to that.

Let us start with

Procfile
. The name comes from process file, and Heroku invented it long enough ago that most PaaS platforms understand it today. It is an ordinary text file with no extension, in which every line has the form: process type name, colon, space, command to run. The name
web
is reserved and means the process that is supposed to receive HTTP traffic from the internet - that is the one the platform will connect to the public address.

1web: uvicorn src.main:app --host 0.0.0.0 --port $PORT

You already know the whole command after the colon from the Docker lesson, and that is the most important observation here: the server has not changed. It is still

uvicorn
, still with the
module:variable
notation and still with the
--host 0.0.0.0
flag, which tells it to listen on every network interface instead of only on the local loopback. Exactly two small things changed. On the left of the colon stands
src.main
instead of
main
, because the
main.py
file lives inside the
src
package and a module path is written with dots. On the right, instead of the number 8000, stands
$PORT
. That notation is a reference to an environment variable: the shell will substitute the value the platform injects when the container starts. And this is the trap that has caught everyone who tried to take a shortcut: do not hardcode a port number there. The platform picks the port it will query you on, changes it between deployments, and if the application insists on 8000 the traffic will simply never reach it. What you get then is a deployment marked as successful in the dashboard and a page that does not load - the most confusing combination in existence.

The second file is specific to Railway and lets you pin down the things that

Procfile
does not cover. It is plain JSON, so all the rules of that format apply: keys in quotes, commas between pairs, no comments. The
build
key describes how to build the application, and the
builder
nested inside it names the build tool. The value
NIXPACKS
means an automaton that will detect Python by the
requirements.txt
file and prepare an image without your involvement. The
deploy
key describes what to do with the finished image:
startCommand
is the start command, and
healthcheckPath
is the address at which the platform will check whether the application is alive.

1{
2  "build": {
3    "builder": "NIXPACKS"
4  },
5  "deploy": {
6    "startCommand": "uvicorn src.main:app --host 0.0.0.0 --port $PORT",
7    "healthcheckPath": "/health"
8  }
9}

The start command here is exactly the same string as in

Procfile
, and that is neither a mistake nor redundancy worth deleting. If both files exist,
startCommand
from
railway.json
wins, because a more specific setting takes precedence over a general convention. It is still worth keeping the
Procfile
: it is portable, and when you move to Render or Heroku tomorrow, the application will start there with no changes at all. If, on the other hand, you would rather the platform built the image from your
Dockerfile
instead of guessing on its own, put the value
DOCKERFILE
in
builder
- then the cloud runs exactly the same sealed crate you test on your own desk, and in a larger project that is usually the option worth considering. Note
healthcheckPath
as well: it is a promise that something sensible will answer at
/health
. The platform will query that address after a deployment and will only consider the copy of the application healthy once it gets a reply. You do not have such an address yet - you will build it in the next lesson, the one about monitoring, together with the rest of the camp's measuring instruments.

A few commands and the application is online

The files are ready, so it is time to send the camp out into the field. Railway is driven from the terminal by a program called

railway
, which installs as a Node.js package. That is a little surprising in a Python project, but the command line tools of cloud providers are written in whatever their authors felt like, and it has no effect whatsoever on your application. The
-g
flag on
npm install
means a global installation, that is one available in every directory on your machine. The next command,
railway login
, opens a browser and links the terminal to your account, and
railway init
creates a new project on the platform side and ties it to the current directory.

1npm install -g @railway/cli
2railway login
3railway init

You run these three commands once per project and that is their entire role - nothing has been deployed yet. After

railway init
a hidden file with the project identifier appears in the directory, which lets the later commands know which camp is meant; you do not have to read it or edit it. In fairness I should warn you that you will not run these commands in the course window and you will not see their output here - they need an account, a browser and a network connection. You will fire them in your own terminal, and what you will see is a series of wizard questions: the project name, the choice of organisation and confirmation of the directory.

Now the deployment proper. A single command packs the contents of the directory, sends it to Railway, builds the image there and starts the container.

1railway up

In this form the command does not give you the terminal back. It sends the project and then stays connected to the platform, streaming the build logs live: installing dependencies, compiling, starting the server. That is very convenient the first time, because you see every step and know immediately where it fell over. It is, on the other hand, a nightmare in a script and in a CI/CD pipeline, where nobody is watching those logs and the process blocks the next step indefinitely. Pressing Ctrl+C does interrupt the view without stopping the deployment itself, but building automation on top of interrupting a process is a remarkably poor idea.

That is what the

--detach
flag is for. Its name comes straight from detached mode, which you already know from Docker: send the job off and come back to the prompt immediately, without waiting for it to finish.

1railway up --detach

The effect is that the command finishes after a few seconds, as soon as the package with the project reaches the platform, while the build carries on on the cloud side. The deployment proceeds in exactly the same way - the flag changes neither the way it is built nor the result, only whether your terminal waits for it. Memorise the order of the parts of this command, because it is always the same and always in this order: first the program name

railway
, then the subcommand
up
, and only at the end the flag
--detach
. That rule holds across the whole world of command line tools - flags come after the subcommand, never before it. Written the other way round the command will not work, because the
railway
program would see an unknown flag before it had a chance to learn which operation you mean.

The application is up, but on its own it is not enough - the species catalogue has to keep its data somewhere. You add a database to the project with the

railway add
command, which lists the available services and lets you pick PostgreSQL from it. A view of what is happening inside the running application is given in turn by
railway logs
.

1railway add
2railway logs

The most interesting thing here happens outside the terminal. After the database is added, the platform itself creates a

DATABASE_URL
environment variable holding the complete address, login and password, and injects it into your application at every start. You do not copy that password anywhere, you do not save it in a file and you do not even have to lay eyes on it. That is the shortest possible answer to the question of what anyone needs environment variables for, and at the same time a natural transition into the second half of the lesson. Before we make it, one note about versions: the exact syntax of
railway add
has changed between releases of that tool - older ones took the database through a
--plugin
flag, newer ones through
--database
or a pick from a list - so if something does not work, look into
railway add --help
instead of fighting the spelling you memorised.

The same road on AWS

Railway is convenient, but sooner or later at work you will meet AWS, the largest cloud provider, whose number of services is enough to make your head spin. The good news is that AWS has its own PaaS and it is called Elastic Beanstalk. The idea is identical to Railway: you hand over the code, the platform builds a machine, installs Python, puts up a proxy server and attaches a public address. It is driven by the

eb
tool, which - this time as you would expect - is an ordinary Python package. The
eb init
command connects the directory with an application on the AWS side, taking the application name and a
--platform
flag with the language version.

1pip install awsebcli
2eb init safari-api --platform python-3.11

The resemblance to Railway is not a coincidence: every PaaS works to the same schema - first a one-off binding of the directory to a project in the cloud, then repeatable shipping of code. The command names differ, the order of the steps is identical. It is only worth remembering that AWS adds one concept Railway did not have: the environment. One application can have several of them, for example production and staging, and each gets separate machines and a separate address. That is why, before the first deployment, you have to create one with the

eb create
command and a chosen name, and only then start sending further versions.

1eb create safari-production
2eb deploy

This is worth pausing over, because the difference in time is noticeable and surprises people on first contact.

eb create
can grind for several minutes - behind the scenes virtual machines, networking, firewall rules and a load balancer come into being.
eb deploy
, by contrast, is something you run hundreds of times afterwards, and it only uploads a new version of the code onto ready infrastructure, so it is considerably faster. You create the environment once and deploy without end - that sentence describes essentially every cloud platform you will meet. And again: you will not run these commands from the course, because they need an AWS account and an access key, and their effect is real, billable resources. If you practise on your own, remember the
eb terminate
command, which deletes an environment - a forgotten machine can generate a bill for many months.

When you want to ship your own image

There is a third road as well, halfway between the convenience of PaaS and the control of IaaS: you build the Docker image yourself, you push it to a registry, and a service in the cloud pulls it and runs it. On AWS that service is called ECS (Elastic Container Service), and the in-house image registry is ECR (Elastic Container Registry). The whole road lends itself beautifully to automation, so we will write it down as workflow steps in GitHub Actions - the skeleton of the file, that is the

name
,
on
and
jobs
sections, you already know from the previous lesson and we will not repeat it. We start with authentication, because without it no
aws
command will work. The ready-made
configure-aws-credentials
action takes three values in its
with
section: the access key, the secret key and the region.

1- name: Configure AWS credentials
2  uses: aws-actions/configure-aws-credentials@v4
3  with:
4    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
5    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
6    aws-region: eu-central-1

The key thing here is the

secrets.NAME
notation inside double curly braces, which you met alongside CI/CD: GitHub substitutes the value of the secret stored in the repository settings, and the run log shows asterisks in its place. There is not and never can be a real key in a workflow file - there is only its name, and the workflow file sits in the repository like any other. It is the same principle that will decide the fate of the
.env
file in a moment, only applied one level higher. The
eu-central-1
region is Frankfurt; you pick one close to your users to shorten the journey of the packets, and you write it in plainly, because it is no secret at all.

Since the CI machine is authenticated, it can log in to the registry and push an image there. The

amazon-ecr-login
action does that for you and - importantly - returns the address of your registry. To be able to refer to that value in the next step, we give the step an identifier with the
id
key and then reach for
steps.login-ecr.outputs.registry
. The
env
section assigns both needed values to environment variables so that the commands in
run
stay readable.

1- name: Login to Amazon ECR
2  id: login-ecr
3  uses: aws-actions/amazon-ecr-login@v2
4
5- name: Build and push image
6  env:
7    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
8    IMAGE_TAG: ${{ github.sha }}
9  run: |
10    docker build -t $ECR_REGISTRY/safari-api:$IMAGE_TAG .
11    docker push $ECR_REGISTRY/safari-api:$IMAGE_TAG

Notice that building the image itself has not changed by a single character from the Docker lesson: it is still

docker build
with the
-t
flag giving the name and a dot pointing at the directory with the
Dockerfile
. The only new part is the name: a full address in the registry instead of the short
safari-api
. The most interesting part, though, is the tag after the colon.
github.sha
is the hash of the commit the workflow was run from, so every image gets a unique, unrepeatable label. It is tempting to tag everything simply as
latest
, but do not do that in production: when a deployment turns out to be faulty, with the
latest
tag you have nothing to go back to, because the previous image had the same name and was overwritten. With a commit tag, undoing a failure is a matter of pointing at an older image and nothing more.

The image is in the registry, but nobody has started it yet. The last step tells the ECS service to replace the running containers with new ones. That is done by the

aws ecs update-service
command, which you give the cluster name, the service name and a flag forcing a fresh deployment.

1- name: Deploy to ECS
2  run: aws ecs update-service --cluster safari --service api --force-new-deployment

The

--force-new-deployment
flag means exactly this: "start again, even if the service description has not changed at all". It is needed precisely because only the image in the registry changed, not the service configuration itself - without it ECS would shrug and leave the old containers in peace. It is worth knowing what happens next, because this behaviour is typical of the whole cloud: ECS starts the new containers alongside the old ones, waits until they begin to answer, and only then shuts the previous ones down. Thanks to that, a deployment does not cut connections for users. The condition is a reply from the health address - the same one you declared in
healthcheckPath
. If the new version cannot serve it, the cloud considers it dead and brings the old one back, and that is the behaviour you want.

A secret that leaks once has leaked forever

One matter remains, and it is the only one in this lesson that can end in a genuine catastrophe. Your application needs a password to the database and a key for signing tokens. It has to get them from somewhere. The most natural reflex in the world suggests simply writing them into a configuration file.

1# src/config.py - this is exactly what we do NOT do
2DATABASE_URL = "postgresql://darwin:secret-password@db.safari.io:5432/safari"
3SECRET_KEY = "token-signing-key"
4DEBUG = True

This file is valid Python, it will run without blinking and it will work splendidly for a week. The problem shows up the moment you run

git add
and
git push
, because from that second on the production database password is written into the repository history. Everyone with access has it, every clone of the repository on every laptop in the team has it, the CI server has it and the backup has it. If the repository ever becomes public, or somebody pushes it to their own account by mistake, the whole internet has the password - and bots combing through fresh commits in search of keys find them within minutes. Memorise the second half of that sentence as well, because it is brutal: removing a secret in a follow-up commit fixes nothing. Git keeps the entire history, so the old commit still contains the password and it can still be read. The only correct reaction to a leak is to revoke the key and generate a new one.

Since a secret cannot live in the code, it needs another home. That home is the environment in which the process starts. You already know environment variables from Docker - those are what you set with the

--env-file
flag on
docker run
- and in Python you reach for them through the
os
module. The
os.getenv
function takes the name of a variable and returns its value as a string, and the optional second argument is a default value, used when the variable is absent.

1import os
2
3print(os.getenv("DATABASE_URL"))
4print(os.getenv("DEBUG", "false"))

Run on a machine where nobody has set those variables, this code prints

None
on the first line and
false
on the second. The secret has gone from the repository, and that is the good news. The bad news is that two new problems appeared, both of them quiet. The first: a missing variable is not an error here at all. The application will start with a database address equal to
None
and will only blow up on the first query, in the middle of serving a user's request, with a message that has nothing to do with the real cause. There is a stricter form,
os.environ["DATABASE_URL"]
, which raises
KeyError
immediately when the variable is missing, and in that respect it is better, but it still does not check whether the value makes sense.

The second problem is sneakier still. Environment variables are always strings - the operating system knows no other type - and what you need is a boolean. The reflex is to push the string through

bool
, and that is a mistake capable of letting debug mode onto production.

1debug_text = "false"
2
3print(bool(debug_text))
4print(debug_text == "true")

The first line prints

True
, the second
False
. The
bool
function does not read the contents of a string, it only checks whether it is non-empty, so the string
"false"
is just as true to it as any other non-empty text. Only a comparison against a specific value, as on the second line, is correct. Picture that mistake on production: the platform dashboard says
DEBUG=false
, the code says
bool(os.getenv("DEBUG"))
, and the application shows users full stack traces along with fragments of the configuration. Repairing this by hand for every variable - one comparison for booleans,
int()
for numbers, and a check against
None
for everything - is twenty lines of boring code in which you will sooner or later make a typo.

Before we fix that, let us settle where to keep these variables while working on your own computer. Typing them in by hand before every run is unbearable, so a convention took hold: a

.env
file, plain text in which every line has the form
NAME=value
, with no spaces around the equals sign and no quotes.

1DATABASE_URL=postgresql://darwin:secret-password@localhost:5432/safari
2SECRET_KEY=token-signing-key
3DEBUG=false

This file contains exactly what we rejected a moment ago in

config.py
, and that is not a contradiction - the difference is that this file never reaches the repository. The database address points at
localhost
here, because this is your local working copy; on production the same three variables will be set by the platform and will have completely different values. That is the whole point of the idea: the same code, a different environment, a different configuration, zero changes in the source files. The guarantee that
.env
stays where it belongs is one line in
.gitignore
, or rather three, because the variants of that name multiply faster than you expect.

1.env
2.env.local
3.env.production

Writing those three patterns down is the cheapest insurance policy in the whole lesson, and do it before you create the

.env
file, not after. While we are at it, let us defuse the three misunderstandings you hear most often about this rule, because none of them is the real reason. We do not exclude
.env
because it is too large - it usually weighs a few hundred bytes, and git versions files thousands of times bigger without effort. We do not exclude it because git supposedly does not support it either - it supports it perfectly, like any other text file, and that is exactly where the trouble lies, because it will obediently write it into history and remember it forever. And it certainly is not that it slows down the build - a few hundred bytes have no effect at all on build time. There is one single reason:
.env
contains secrets and sensitive data, and everything that reaches the repository stops being a secret. Notice that you wrote the very same
.env
line once before, in the Docker lesson, into the
.dockerignore
file, and for exactly the same reason - to keep the password from travelling to the registry inside the image.

Since

.env
does not reach the repository, a new person on the team has no way of knowing which variables the application even needs. That is why a second file,
.env.example
, is kept next to it, with an identical structure but placeholder values instead of real ones. This one you do commit, and it is the documentation of the configuration.

1DATABASE_URL=postgresql://user:password@localhost:5432/safari
2SECRET_KEY=change-me
3DEBUG=false

A new person copies this file to

.env
, swaps the values for their own and has a working environment within a minute. Notice what has not changed: the variable names and their order are identical to the real
.env
, because the entire value of this file lies in its being a faithful skeleton. Only the values have gone. Just watch one thing with iron consistency: when you add a new variable to the application, add it to
.env.example
in the same moment. A forgotten variable shows up a week later as an application that works for everybody except the new colleague, on whose machine it dies at startup with no sensible message.

One class instead of twenty os.getenv calls

Let us go back to those twenty lines of manual type checking, because there is a ready-made cure for them. It is called pydantic-settings and it comes from the family of the pydantic library, which describes data with classes annotated with types and takes care that the values match those annotations. It is installed separately, with

pip install pydantic-settings
, because in the second version of pydantic it was split out into its own package - if you see an import of
BaseSettings
straight from
pydantic
in an older tutorial, that is the spelling from before that change and it no longer works today.

A configuration class is created by inheriting from

BaseSettings
. You declare every field like an ordinary annotation: name, colon, type, and optionally an equals sign and a default value. A field with no default value is required. Where to read the data from is set by the class field
model_config
, to which you assign a
SettingsConfigDict
with an
env_file
parameter naming the file. We write field names in lower case, and the library will match them to environment variables regardless of case, so the
database_url
field takes its value from the
DATABASE_URL
variable.

1from pydantic_settings import BaseSettings, SettingsConfigDict
2
3
4class Settings(BaseSettings):
5    model_config = SettingsConfigDict(env_file=".env")
6
7    database_url: str
8    secret_key: str
9    debug: bool = False
10
11
12settings = Settings()
13
14print(settings.database_url)
15print(settings.debug, type(settings.debug))

Run in a directory holding the

.env
file shown earlier, this prints the complete database address on the first line and
False <class 'bool'>
on the second. Look at that second line carefully, because the entire value of this solution sits in it: the file held the string
false
, and
settings.debug
holds a genuine boolean
False
. The conversion happened by itself, on the strength of the
bool
annotation, and the
bool("false")
trap simply ceased to exist. In the same way a field described as
int
will get a number, not a string of digits. Notice, too, what is not here: not a single
os.getenv
call, not a single manual conversion, not a single check for whether a value exists. There are no quotes around the values in
.env
either - the library needs no decorations. Reading configuration in the rest of the application comes down from now on to
settings.database_url
, and your editor will suggest the field names to you, because they are ordinary class attributes. One warning at the end of this block: I print the database address here only because it is made up and local. In a real application never log
secret_key
or a password
- a secret in the logs is a leaked secret in exactly the same way as a secret in a repository.

There is one more fork in the syntax here and one clear recommendation from me. In almost every older tutorial you will see, instead of the

model_config
field, a nested class named
Config
with the same
env_file
entry inside it. That spelling still works, but pydantic prints a deprecation warning next to it and announces its removal in the third version of the library. I recommend
SettingsConfigDict
, and not because of fashion: it is an ordinary object with named parameters, so the editor will suggest the available options and a typo in a parameter name will be noticed straight away. To tooling, a nested class is a sack you can throw anything into, and a mistake in a setting name is something you discover only when you notice that it is doing nothing.

The most important thing, though, happens when something is missing. Let us see what happens when you run the same code in a directory with no

.env
file and no variables set. So that this is comfortable to look at, we will surround the creation of the object with a
try
block and catch and print
ValidationError
, the exception pydantic uses to report data that does not fit.

1from pydantic import ValidationError
2from pydantic_settings import BaseSettings, SettingsConfigDict
3
4
5class Settings(BaseSettings):
6    model_config = SettingsConfigDict(env_file=".env")
7
8    database_url: str
9    secret_key: str
10    debug: bool = False
11
12
13try:
14    settings = Settings()
15except ValidationError as error:
16    print(error)

The printed message opens with a sentence about two validation errors in the

Settings
class, and underneath it names both missing fields -
database_url
and
secret_key
- marking each of them
Field required
. The
debug
field is not listed, because it has a default value and nobody demanded it. Notice the moment at which this happens: when the object is created, that is at application startup, and not on the first database query half an hour later. An application with incomplete configuration will not come up at all, the platform will see a failed deployment and will leave the previous, working version on production. That is exactly the behaviour you want, and the main reason I recommend
Settings
over
os.getenv
calls scattered through the code - those will return
None
, pretend everything is fine, and move the failure to the worst possible moment.

There remains the question everyone asks on first seeing

env_file=".env"
: if the
.env
file does not travel to production, where will the application get its configuration there? The answer is: from the environment variables set by the platform, and the
Settings
class reads both sources at once, with the environment variable taking precedence over the file. Let us check that by setting a variable from inside Python through
os.environ
, the dictionary holding the whole environment of the process, even though the
.env
file says the opposite.

1import os
2
3from pydantic_settings import BaseSettings, SettingsConfigDict
4
5
6class Settings(BaseSettings):
7    model_config = SettingsConfigDict(env_file=".env")
8
9    database_url: str
10    secret_key: str
11    debug: bool = False
12
13
14os.environ["DEBUG"] = "true"
15
16settings = Settings()
17print(settings.debug)

It prints

True
, even though
.env
says
false
. That order is not a whim of the library's authors, it is the foundation of the entire deployment: on production there is no
.env
at all, so the class takes everything from the environment set by Railway or AWS, while locally the very same code, without the slightest change, reads your file. Just remember that modifying
os.environ
in code makes sense only in a demonstration like this one - in a real application the variables are set by the platform, not by you from inside the process. And by the way: if you put something into
.env
that cannot be read as a boolean, for example
DEBUG=maybe
, you would get the same
ValidationError
as a moment ago, this time with a message saying the library cannot interpret that entry as a boolean. A typo in the platform dashboard will stop the deployment instead of slipping by unnoticed.

Finally, a detail that will save you a quarter of an hour of bewilderment. When you run mypy from the code quality lesson over this code, you will get a surprising error:

Missing named argument "database_url" for "Settings"
. Mypy sees an ordinary class and reckons that since the fields have no default values they have to be passed in the call - it knows nothing about pydantic filling them from the environment. The solution is a plugin shipped with the library, switched on in the same
pyproject.toml
where you already keep the configuration for pytest, Ruff and mypy.

1[tool.mypy]
2python_version = "3.11"
3strict = true
4plugins = ["pydantic.mypy"]

After adding that single line with

plugins
, mypy stops protesting and passes without complaint even with
strict
switched on. Notice that the rest of the configuration stayed untouched -
python_version
and
strict
are where they were, and the plugin merely gives mypy knowledge of how pydantic builds classes. The value is a list, so there can be several plugins. This trick is worth remembering as a general rule: when a type checker reports an error in code you are sure works, look for a plugin for the library you are using before you start silencing warnings with comments.

What happens after a single git push

Let us roll this up into one story, because the sense of it only shows in the whole. You type

git push
. GitHub Actions wakes up and runs the pipeline from the previous lesson: lint, tests, build. If anything goes red, the story ends there and the previous version keeps running on production. If everything passed, the platform steps in. Nixpacks or your
Dockerfile
build the image, the platform starts it with the command from
startCommand
, substituting its own port number for
$PORT
and injecting the full set of environment variables from the dashboard. The
Settings
class reads them at startup and either finds the complete set or aborts the start immediately. The new copy reports in at the address from
healthcheckPath
, the platform waits for its reply, switches the traffic over to it and only then shuts the old one down. That whole road takes a few minutes and does not require a single login to a server from you.

This is a good moment to say honestly what this lesson did not give you. You know how to send an application out into the world, but you have no way yet of finding out what is happening to it at three in the morning. You declared a

/health
address to the platform that nobody has built yet. You have no logs you can filter, no numbers you can compare against yesterday's, and nobody to wake you when the database stops answering. That is exactly what we take up in the next lesson, about monitoring, setting camera traps and sensors up around the camp.

Remember one sentence from this lesson, @name: deployment means the code travels to the cloud while the secrets stay in the environment - you send the expedition crate out into the world, but you carry its keys on you.

Go to CodeWorlds