Introduction
Have you ever had the issue where every time you release a website to a new environment, you have custom settings in the web.config that need to change for each environment? For instance, when going from your dev server to a test server, your connection string needs to change. Or from test to prod, your SMTP host needs to change. This article shows a solution where you set up your web.config once and then let the code do the work of deciding which environment it is in.
Background
I have found when deploying a website to a different environment, I often do not copy out the web.config file. I am worried I will forget some of the custom settings for that environment. Sometimes this causes issues because of changes that have been made to the web.config file that are new. I decided I wanted a solution where you can set up your web.config file for each of your environments and then you don't have to worry about the rest. You just release the single web.config file and let the system take care of the custom settings for each environment.
The Problem
I have found myself never overwriting existing web.config files on prod and test servers. I would have to manually modify these web.config files whenever they were modified in the dev with the correct values for test and prod. I really didn't want to have to do this anymore.
The Solution
With ASP.NET 2.0, we have been given a lot of new functionality with how we can work with the web.config file. So I created a custom section in the web.config that defines which servers are in which environments. Next, I created another custom section that defines each environment. Finally, I used some custom classes to access our config settings. Accessing this custom class (EnvironmentSettings
) once will fix the config files for the current environment. I used static
(Shared
in VB) constructors so the code only accesses the config file once. Then you can use the static EnvironmentSettings
class to access your config settings.
Some Details
Here is the first section the web.config needs:
<configSections>
<section name="environmentConfiguration"
type="ConfigIt.KeyValueConfigurationSection, ConfigIt"/>
<section name="dev" type="ConfigIt.KeyValueConfigurationSection, ConfigIt"/>
<section name="test" type="ConfigIt.KeyValueConfigurationSection, ConfigIt"/>
<section name="prod" type="ConfigIt.KeyValueConfigurationSection, ConfigIt"/>
</configSections>
configSections
goes right into your web.config file. It defines the custom sections we are going to add.
Next, we have a custom section that defines our servers:
<environmentConfiguration>
<elements>
<add key="devpcname" value="dev" />
<add key="testpcname" value="test" />
<add key="prodpcname" value="prod" />
</elements>
</environmentConfiguration>
Notice that "environmentConfiguration
" was defined in the configSections
above. Here is where you put your PC and server names. You don't actually have to put the dev PC/server names in since that is what the code assumes if it can't find the machine name in the list defined here. It is important that you have your test and prod server names here.
Next, we have the dev
, test
, and prod
custom sections:
<dev>
<elements>
<add key="SystemEmailAddress" value="administrator@d.com" />
<add key="SMTPServer" value="devSMTP" />
</elements>
<connectionStrings>
<add name="YourDatabase"
connectionString="Database=YourDatabase;Server=devsqlpcname;uid=DevUser;pwd=DevUser;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</dev>
<test>
<elements>
<add key="SystemEmailAddress" value="administrator@t.com" />
<add key="SMTPServer" value="testSMTP" />
</elements>
<connectionStrings>
<add name="YourDatabase"
connectionString="Database=YourDatabase;Server=testsqlpcname;uid=TestUser;pwd=TestUser;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</test>
<prod>
<elements>
<add key="SystemEmailAddress" value="administrator@p.com" />
<add key="SMTPServer" value="prodSMTP" />
</elements>
<connectionStrings>
<add name="YourDatabase"
connectionString="Database=YourDatabase;Server=prodsqlpcname;uid=ProdUser;pwd=ProdUser;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</prod>
So instead of using appSetting
, you can add a new key value pair in the elements
section of the correct environment. Notice that each environment has defined a connectionStrings
section.
Next, let's look at the normal connectionStrings
section in the web.config file:
<connectionStrings configSource="ConnectionStrings.config" />
There is nothing there except a configSource
. configSource
tells this section that there is an external file that has the connectionStrings
section in it. Note: it is important that external config files still have a .config extension. These extensions can not be browsed by a web browser. Here is the ConnectionStrings.config file:
="1.0" ="utf-8"
<connectionStrings>
<clear />
<add name="YourDatabase" connectionString=""
providerName="System.Data.SqlClient" />
</connectionStrings>
Notice that the connectionString
attribute is empty. It is going to be overwritten by the correct value in the environment sections defined above.
OK, at this point, you may be asking what is this all about? Why do we need an external file? Well, I can tell you, the first time I tried this, I didn't use an external file. There was a problem though. The web.config file would get updated, but the WebConfigurationManager.ConnectionStrings
did not reload even through the file changed. Now I have my suspicions that, that is because it is the IIS worker process that is actually changing the config file, but I don't really know for sure. So when using an external config file, you can set a property called "RestartOnExternalChanges
". When this is set to true
, then WebConfigurationManager.ConnectionStrings
does get updated.
OK, you still may wonder why you care about this variable? Can't you just use EnvironmentSettings.ConnectionStrings
? The answer is yes, you can, but... If you are currently using a roleManager
, inside of the providers
, there is an attribute called connectionStringName
. That uses the WebConfigurationManager.ConnectionStrings
, not your custom section. The same is true for profile
and membership
. So if you are using any of those, it is important that the WebConfigurationManager
gets updated with the correct connection strings. Also, if you are using a SqlDataSource
, you set the connectionString
property in the HTML to "<%$ ConnectionStrings:NorthwindConnectionString %>
"; you are not using your custom section ConnectionStrings
. So you are in the same situation there.
The SMTP (email server) name is in the same type of situation, if you are using the ASP.NET 2.0 login controls. All of the email functionality is assuming you have set up your web.config file with the correct structure that includes an SMTP email server name (host).
So how does it work? I have already discussed the web.config file with the custom sections, now let us move on to the code. I have created a separate Class Library project so that it is easy to add this function to any web project. Inside of the class library, there are two classes.
The first is the EnvironmentSettings
class. The static constructor in this class is what looks at the custom sections, decides which machine the code is running on, gets the correct custom environment, and then compares the custom settings for connection strings and SMTP with what the web.config currently has. If they don't match up the custom settings, overwrite the current setting in the config file and the file is saved. Since this is a static
(Shared
in VB) construction, it should only happen once per appdomain. In the download samples, you will see I have added a Global.asax file that has an application_Start
event. In this event, I access the static class so the constructor is called.
The other class in the Class Library is KeyValueConfigurationSection
. This class defines the custom sections we have added to the web.config file. It is also the class we point to the configSections
part of the web.config. Here is what the static constructor looks like in C# (I have VB.NET source code download available as well):
static EnvironmentSettings()
{
KeyValueConfigurationSection section = null;
Configuration config =
System.Web.Configuration.WebConfigurationManager.OpenWebConfiguration("~",null,null,
System.Environment.MachineName,
System.Environment.MachineName + @"\WebConfigUser","WebConfigPassword");
section = config.GetSection("environmentConfiguration")
as KeyValueConfigurationSection;
string machineName = System.Environment.MachineName.ToLower();
if (section.Elements[machineName] != null &&
section.Elements[machineName].Value != null &&
section.Elements[machineName].Value != string.Empty)
{
_environment = section.Elements[machineName].Value;
}
else
{
_environment = "dev";
}
_elements = ((KeyValueConfigurationSection)config.GetSection(_environment)).Elements;
_connectionStrings =
((KeyValueConfigurationSection)config.GetSection(_environment)).ConnectionStrings;
Boolean somethingChanged = false;
_systemNet = (System.Net.Configuration.NetSectionGroup)
config.GetSectionGroup("system.net");
if (_systemNet.MailSettings.Smtp.Network.Host != null &&
_elements["SMTPServer"] != null &&
_systemNet.MailSettings.Smtp.Network.Host !=
_elements["SMTPServer"].Value)
{
_systemNet.MailSettings.Smtp.Network.Host = _elements["SMTPServer"].Value;
somethingChanged = true;
}
foreach (ConnectionStringSettings css in _connectionStrings)
{
Boolean foundIt = false;
foreach (ConnectionStringSettings css2 in config.ConnectionStrings.ConnectionStrings)
{
if (css.Name.Trim() == css2.Name.Trim())
{
foundIt = true;
if (css2.ConnectionString != css.ConnectionString)
{
css2.ConnectionString = css.ConnectionString;
config.ConnectionStrings.SectionInformation.RestartOnExternalChanges = true;
somethingChanged = true;
}
}
}
if (!foundIt)
{
config.ConnectionStrings.ConnectionStrings.Add(css);
config.ConnectionStrings.SectionInformation.RestartOnExternalChanges = true;
somethingChanged = true;
}
}
if (somethingChanged)
{
config.Save(ConfigurationSaveMode.Modified);
}
}
The last step is how the website updates the web.config file. The answer is you need to create a local user on the machines this website will be running on. Then you give that user read and write rights so it can update the web.config file. Note: you could just give ASPNET user rights, but I would not suggest this since it is kind of a security no-no. Next, you need to add the IIS_WPG work process group to have read and write rights to the folder as well.
Here are some examples of accessing configuration properties in my sample page_load
event in C#:
string databaseName = "YourDatabase";
lbEnvior.Text = "You are running in the " +
EnvironmentSettings.Environment + " Environment";
lbEnvirSQL.Text = EnvironmentSettings.ConnectionStrings[databaseName].ConnectionString;
lbEnvirSMTP.Text = EnvironmentSettings.SystemNet.MailSettings.Smtp.Network.Host;
lbWebConfigManSQL.Text =
System.Web.Configuration.WebConfigurationManager.
ConnectionStrings[databaseName].ConnectionString;
System.Configuration.Configuration config =
System.Web.Configuration.WebConfigurationManager.OpenWebConfiguration("~");
lbWebConfigDirectSQL.Text =
config.ConnectionStrings.ConnectionStrings[databaseName].ConnectionString;
System.Net.Configuration.NetSectionGroup _systemNet =
(System.Net.Configuration.NetSectionGroup)config.GetSectionGroup("system.net");
lbWebConfigDirectSMTP.Text = _systemNet.MailSettings.Smtp.Network.Host;
lbConfigManSQL.Text = ConfigurationManager.ConnectionStrings[databaseName].ConnectionString;
The Steps
Note: you will need admin rights for some of these steps:
- Create a local user (I picked WebConfigUser) on the machine the site is going to run on. Right click My Computer. Click Manage. Click the plus sign on Local Users and Groups. Right click on the Users folder. Click New User. Type in the user name and full name and password. Then, un-check User must change password at login and check Password never expires. Click Create.
- Go to the directory the web.config is in. Right click the folder, choose Properties. Select the Security tab. Click the Add button. You may need to click the Locations button so that your location is your computer, not the domain. Click OK. Check the Modify check box. Next, Add the IIS_WPG group. Make sure to check the Modify check box. Click OK.
- In the
EnvironmentSettings
class constructor, there is a call to OpenWebConfiguration
. Inside of this call, you need to have the user name and password of the User local user you just created.
- In web.config, add a
configSections
. You should be able to copy the one in the download sample files.
- In web.config, add the
environmentConfiguration
custom section. This is where you list your servers.
- In web.config, add the
dev
, test
, prod
, or whatever you want to call your environments. Make sure each environment section has a ConnectionStrings
section in it.
- In Web.config, point the existing
ConnectionStrings
section to an external file. Create the external file with the correct code in it. Same with the SMTP section. The download sample code should work here. You just need to change the connection name and connection string to match your system.
- Add the Class Library project (ConfigItC# or ConfigItVB) to your web project solution. Add a reference to it in the web project.
- Add a global.asax file and add an
application_start
method. You can use the download sample one.
Trimmed Down Version
If you really don't think you will ever be using the WebConfigurationManager.ConnectionStrings
or the SMTP stuff, you can always change the EnvironmentSettings
class. Remove OpenWebconfiguration
and all the code that compares stuff and saves config files.
Conclusion
I hope you find this solution to managing your web.config files across environments helpful. It was kind of a pain to figure out, but now that it is finally working, it really saves me a bunch of headaches. I do want to acknowledge Steve Rowe for his help with some of the details of setting up custom configuration settings.