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.
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.
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 .gitignorePay 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 $PORTYou 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.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 initYou 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 upIn 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 --detachThe 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 logsThe 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.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.11The 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 deployThis 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.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-1The 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_TAGNotice 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-deploymentThe
--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.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 = TrueThis 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=falseThis 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.productionWriting 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=falseA 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.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.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.